Enable SSH and TLS handshake on prober for TCP22 and TCP443 ports

This commit is contained in:
ayurishchev committed 2026-08-26 23:52:31 +03:00
1 parent af3453f19f
commit 550ce3fec4
11 files changed
+308 -20

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
package checkrunner
import (
"context"
"crypto/tls"
"net"
"strconv"
"time"
)
// TLSHandshake performs a real TLS handshake (not just a TCP connect)
// against host:port and reports success if it completes within timeout.
// Certificate validation is intentionally skipped: the target is a bare
// candidate IP under test before any DNS/hostname is attached to it, so
// there's neither a hostname to validate the cert against nor any reason
// to expect a signed cert yet. This checks "is there a real TLS listener
// here", not "is its certificate valid" — same scope-limiting principle
// SSHBanner already applies (banner only, no auth handshake).
func TLSHandshake(host string, port int, timeout time.Duration) func(ctx context.Context) Result {
target := net.JoinHostPort(host, strconv.Itoa(port))
checkType := "tls-" + strconv.Itoa(port)
return run(checkType, target, func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := tls.Dialer{
NetDialer: &net.Dialer{Timeout: timeout},
Config: &tls.Config{InsecureSkipVerify: true},
}
conn, err := d.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
return conn.Close()
})
}
+70
View File
@@ -0,0 +1,70 @@
package checkrunner
import (
"context"
"net"
"net/http/httptest"
"strconv"
"testing"
"time"
)
func splitHostPortInt(t *testing.T, addr string) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host port %q: %v", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("parse port %q: %v", portStr, err)
}
return host, port
}
// TestTLSHandshakeSucceedsAgainstTLSServer confirms a real TLS listener
// (self-signed cert, InsecureSkipVerify accepts it) passes the handshake.
func TestTLSHandshakeSucceedsAgainstTLSServer(t *testing.T) {
ts := httptest.NewTLSServer(nil)
defer ts.Close()
host, port := splitHostPortInt(t, ts.Listener.Addr().String())
res := TLSHandshake(host, port, time.Second)(context.Background())
if !res.Success {
t.Fatalf("expected success, got failure: %s", res.Detail)
}
wantType := "tls-" + strconv.Itoa(port)
if res.CheckType != wantType {
t.Fatalf("expected check type %q, got %q", wantType, res.CheckType)
}
}
// TestTLSHandshakeFailsAgainstPlainTCP confirms a bare TCP listener (no TLS
// on top) fails the handshake rather than being mistaken for success —
// this is exactly the gap a plain TCPConnect check can't catch.
func TestTLSHandshakeFailsAgainstPlainTCP(t *testing.T) {
ts := httptest.NewServer(nil)
defer ts.Close()
host, port := splitHostPortInt(t, ts.Listener.Addr().String())
res := TLSHandshake(host, port, time.Second)(context.Background())
if res.Success {
t.Fatalf("expected failure against a plain TCP listener, got success")
}
}
// TestTLSHandshakeFailsOnConnectionRefused confirms a closed port reports
// failure rather than hanging or panicking.
func TestTLSHandshakeFailsOnConnectionRefused(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
_, port := splitHostPortInt(t, l.Addr().String())
l.Close() // close immediately so the port refuses connections
res := TLSHandshake("127.0.0.1", port, time.Second)(context.Background())
if res.Success {
t.Fatalf("expected failure against a closed port, got success")
}
}
+1
View File
@@ -176,6 +176,7 @@ func TestEndToEndHTTPFlow(t *testing.T) {
resp, body = fc.do(http.MethodPost, "/api/v1/probers/"+site+"/results", proberResultsRequest{
Results: []proberResultDTO{
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-22", Success: true, CheckedAt: now},
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "ssh", Success: true, CheckedAt: now},
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-80", Success: true, CheckedAt: now},
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "icmp", Success: true, CheckedAt: now, Complete: true},
},
+5
View File
@@ -531,6 +531,11 @@ func (o *Orchestrator) expectedCheckCount(ctx context.Context) (int, error) {
if inbound.ICMP {
inboundPerSite++
}
for _, p := range inbound.Ports {
if p == 443 || p == 22 {
inboundPerSite++
}
}
return egress + inboundPerSite*len(sites), nil
}
+113 -11
View File
@@ -127,7 +127,7 @@ func TestHappyPath(t *testing.T) {
// 4. inbound results from all 3 sites
for site := 1; site <= 3; site++ {
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
for _, ct := range []string{"tcp-22", "ssh", "tcp-80", "icmp"} {
if err := o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(site), CheckType: ct, Target: ip.IPAddress,
@@ -558,7 +558,7 @@ func TestInboundChecksPartialSites(t *testing.T) {
t.Fatalf("expected still checking (site-1 pending), got %s", ip.State)
}
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
for _, ct := range []string{"tcp-22", "ssh", "tcp-80", "icmp"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(1), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
@@ -585,7 +585,7 @@ func TestExpectedCheckCountReflectsInboundChecksConfigChange(t *testing.T) {
sites := []config.SiteConfig{{SiteID: "site-1", Index: 1}}
o, d, mock := newTestOrchestratorWithSites(t, 180, sites)
// newTestOrchestratorWithSites seeds Inbound: {Ports: [22, 80], ICMP: true}
// (3 inbound checks expected per site).
// (4 inbound checks expected per site: tcp-22, ssh, tcp-80, icmp).
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4"})
@@ -603,16 +603,19 @@ func TestExpectedCheckCountReflectsInboundChecksConfigChange(t *testing.T) {
_ = o.MarkEgressComplete(ctx, ip.ID)
// Shrink the inbound check config to a single TCP port, no ICMP — an
// admin API change made between assignment and aggregation.
// admin API change made between assignment and aggregation. Port 22
// still auto-triggers the extra "ssh" check (see expectedCheckCount).
if err := d.SetInboundChecks(ctx, []int{22}, false); err != nil {
t.Fatalf("set inbound checks: %v", err)
}
// Report only the one now-expected inbound check.
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(1), CheckType: "tcp-22", Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
// Report only the two now-expected inbound checks.
for _, ct := range []string{"tcp-22", "ssh"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(1), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
}
_ = o.MarkSiteComplete(ctx, ip.ID, 1)
o.Tick(ctx)
@@ -652,7 +655,7 @@ func TestFourSitesAllMustReportBeforeAggregation(t *testing.T) {
_ = o.MarkEgressComplete(ctx, ip.ID)
for _, site := range []int{1, 2, 3} {
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
for _, ct := range []string{"tcp-22", "ssh", "tcp-80", "icmp"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(site), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
@@ -669,7 +672,7 @@ func TestFourSitesAllMustReportBeforeAggregation(t *testing.T) {
t.Fatalf("expected still checking (site-4 pending), got %s", ip.State)
}
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
for _, ct := range []string{"tcp-22", "ssh", "tcp-80", "icmp"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(4), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
@@ -686,3 +689,102 @@ func TestFourSitesAllMustReportBeforeAggregation(t *testing.T) {
t.Fatalf("expected pass, got %s", ip.OverallResult)
}
}
// TestExpectedCheckCountIncludesTLSAndSSHForPorts443And22 confirms
// expectedCheckCount counts the auto-triggered tls-443/ssh checks (in
// addition to the base tcp-443/tcp-22) once those ports are configured —
// reporting exactly that full set must be enough to aggregate as pass.
func TestExpectedCheckCountIncludesTLSAndSSHForPorts443And22(t *testing.T) {
ctx := context.Background()
sites := []config.SiteConfig{{SiteID: "site-1", Index: 1}}
o, d, mock := newTestOrchestratorWithSites(t, 180, sites)
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4"})
o.Tick(ctx)
ip, _ := d.GetIPByAddress(ctx, "1.2.3.4")
_ = o.SelfCheckResult(ctx, "validator-1", ip.ID, true, "ok")
ip, _ = d.GetIP(ctx, ip.ID)
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
ValidatorID: "validator-1", Source: db.SourceEgress, CheckType: "https",
Target: "https://example.test", Success: true, CheckedAt: db.Now(),
})
_ = o.MarkEgressComplete(ctx, ip.ID)
if err := d.SetInboundChecks(ctx, []int{22, 443}, false); err != nil {
t.Fatalf("set inbound checks: %v", err)
}
for _, ct := range []string{"tcp-22", "ssh", "tcp-443", "tls-443"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(1), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
}
_ = o.MarkSiteComplete(ctx, ip.ID, 1)
o.Tick(ctx)
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != db.IPDone {
t.Fatalf("expected done once tcp+tls+ssh all reported, got %s", ip.State)
}
if ip.OverallResult != db.ResultPass {
t.Fatalf("expected pass, got %s", ip.OverallResult)
}
}
// TestAggregationWaitsForTLSAndSSHResults confirms that reporting only the
// base tcp-22/tcp-443 checks (without the auto-triggered ssh/tls-443
// checks) is not enough to aggregate as pass — expectedCheckCount must
// actually have grown, not just failed to break.
func TestAggregationWaitsForTLSAndSSHResults(t *testing.T) {
ctx := context.Background()
sites := []config.SiteConfig{{SiteID: "site-1", Index: 1}}
o, d, mock := newTestOrchestratorWithSites(t, 180, sites)
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4"})
o.Tick(ctx)
ip, _ := d.GetIPByAddress(ctx, "1.2.3.4")
_ = o.SelfCheckResult(ctx, "validator-1", ip.ID, true, "ok")
ip, _ = d.GetIP(ctx, ip.ID)
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
ValidatorID: "validator-1", Source: db.SourceEgress, CheckType: "https",
Target: "https://example.test", Success: true, CheckedAt: db.Now(),
})
_ = o.MarkEgressComplete(ctx, ip.ID)
if err := d.SetInboundChecks(ctx, []int{22, 443}, false); err != nil {
t.Fatalf("set inbound checks: %v", err)
}
// Only the base TCP checks report — ssh/tls-443 never arrive, but the
// site still (incorrectly, from the operator's point of view) claims
// completion. Force the checking window to have elapsed so aggregation
// runs anyway, same as TestPartialResult.
for _, ct := range []string{"tcp-22", "tcp-443"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(1), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
}
_ = o.MarkSiteComplete(ctx, ip.ID, 1)
o.Cfg.CheckingWindowSeconds = 0
time.Sleep(5 * time.Millisecond)
o.Tick(ctx)
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != db.IPDone {
t.Fatalf("expected done, got %s", ip.State)
}
if ip.OverallResult != db.ResultPartial {
t.Fatalf("expected partial (missing ssh/tls-443 counted against it), got %s", ip.OverallResult)
}
}
+28 -8
View File
@@ -109,26 +109,46 @@ func (p *Prober) pollOnce(ctx context.Context) {
}
}
// extraChecksForPort returns checks to run in addition to the base
// TCPConnect for ports where a bare TCP handshake doesn't actually prove
// the expected service is behind it: a real TLS handshake on 443, a real
// SSH banner exchange on 22. nil for every other port.
func extraChecksForPort(host string, port int, timeout time.Duration) []func(ctx context.Context) checkrunner.Result {
switch port {
case 443:
return []func(ctx context.Context) checkrunner.Result{
checkrunner.TLSHandshake(host, port, timeout),
}
case 22:
return []func(ctx context.Context) checkrunner.Result{
checkrunner.SSHBanner(host, timeout),
}
default:
return nil
}
}
func (p *Prober) probeOne(ctx context.Context, a assignment) {
tcpTimeout := time.Duration(p.cfg.Checks.TCPTimeoutSeconds) * time.Second
icmpTimeout := time.Duration(p.cfg.Checks.ICMPTimeoutSeconds) * time.Second
var results []resultDTO
for _, port := range a.Ports {
res := checkrunner.TCPConnect(a.IPAddress, port, tcpTimeout)(ctx)
record := func(res checkrunner.Result) {
results = append(results, resultDTO{
IPID: a.IPID, IPAddress: a.IPAddress, CheckType: res.CheckType,
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail,
CheckedAt: res.CheckedAt.Format(time.RFC3339Nano),
})
}
for _, port := range a.Ports {
record(checkrunner.TCPConnect(a.IPAddress, port, tcpTimeout)(ctx))
for _, extra := range extraChecksForPort(a.IPAddress, port, tcpTimeout) {
record(extra(ctx))
}
}
if a.ICMP {
res := checkrunner.ICMPEcho(a.IPAddress, p.cfg.Checks.ICMPCount, icmpTimeout)(ctx)
results = append(results, resultDTO{
IPID: a.IPID, IPAddress: a.IPAddress, CheckType: res.CheckType,
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail,
CheckedAt: res.CheckedAt.Format(time.RFC3339Nano),
})
record(checkrunner.ICMPEcho(a.IPAddress, p.cfg.Checks.ICMPCount, icmpTimeout)(ctx))
}
if len(results) > 0 {
results[len(results)-1].Complete = true
+33
View File
@@ -94,3 +94,36 @@ func TestPollOnceSkipsAssignmentsWhenHeartbeatFails(t *testing.T) {
t.Fatalf("expected only [heartbeat] (assignments skipped on heartbeat failure), got %v", calls)
}
}
// TestExtraChecksForPort confirms only ports 443 (TLS) and 22 (SSH) get an
// extra protocol check beyond the baseline TCPConnect every port gets.
func TestExtraChecksForPort(t *testing.T) {
// Use a host nothing is listening on so the extra check's own network
// call fails fast — only its CheckType is asserted here, the
// port->check mapping, not real connectivity.
closedHost := "127.0.0.1"
cases := []struct {
port int
wantLen int
wantCheckType string
}{
{port: 443, wantLen: 1, wantCheckType: "tls-443"},
{port: 22, wantLen: 1, wantCheckType: "ssh"},
{port: 80, wantLen: 0},
{port: 8080, wantLen: 0},
}
for _, tc := range cases {
extra := extraChecksForPort(closedHost, tc.port, 100*time.Millisecond)
if len(extra) != tc.wantLen {
t.Fatalf("port %d: expected %d extra checks, got %d", tc.port, tc.wantLen, len(extra))
}
if tc.wantLen == 0 {
continue
}
res := extra[0](context.Background())
if res.CheckType != tc.wantCheckType {
t.Fatalf("port %d: expected check type %q, got %q", tc.port, tc.wantCheckType, res.CheckType)
}
}
}