diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index 9f34b9a..2293b87 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,4 +1,4 @@ 118e09f98944ac59593af603c04a1d5f0e7cafb6549b5a562300bc31d9c769da control-api 48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent -8877cf7c6d2bdd58faf70865be602b3db5f18508f4607c8942bc8899ce0fb1e6 prober +c7b4c2464a5974237b1f98f1565884f09b1ee91a1efadb4b8e1e7d9aaf0b9fe2 prober b0339b9b341d9ab2d4f6835fda5d9f927a18ed8a831ec9f24dba05624664a417 admin-dashboard diff --git a/bin/prober b/bin/prober index c52c44c..022012a 100755 Binary files a/bin/prober and b/bin/prober differ diff --git a/docs/API.md b/docs/API.md index 95a3e0c..a51fe47 100644 --- a/docs/API.md +++ b/docs/API.md @@ -262,8 +262,10 @@ USAGE.md про состояния площадки). { "results": [ {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-22", "success": true, "latency_ms": 12, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, + {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "ssh", "success": true, "latency_ms": 15, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-80", "success": true, "latency_ms": 9, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-443", "success": true, "latency_ms": 10, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, + {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tls-443", "success": true, "latency_ms": 34, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-8080","success": false,"latency_ms": 0, "checked_at": "2026-08-21T09:15:01Z", "complete": false}, {"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "icmp", "success": true, "latency_ms": 5, "checked_at": "2026-08-21T09:15:01Z", "complete": true} ] @@ -275,6 +277,16 @@ USAGE.md про состояния площадки). IP на данном проходе". До этого момента control-api не будет считать данные с этой площадки завершёнными. +Порты `22` и `443` в `ports` — особый случай: если они присутствуют в +списке, `prober` дополнительно к базовой `tcp-`-проверке доступности +выполняет **настоящий** протокольный чек — `ssh` (обмен SSH-банером на +порту 22) и `tls-443` (полноценный TLS-хендшейк на порту 443) +соответственно. Это не отдельно настраиваемые типы проверок — они +включаются/выключаются автоматически вместе с самим портом, без +дополнительного флага. Сертификат в `tls-443` не валидируется +(`InsecureSkipVerify`) — проверяется только сам факт TLS-хендшейка, не +доверие к серверу. + Ответ: `{"ok": true}`. ## Служебные и административные методы diff --git a/docs/USAGE.md b/docs/USAGE.md index 4ac16e8..f0ecf6a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -324,6 +324,16 @@ curl -s -X PUT http://:8080/api/v1/admin/config/inbound-checks \ паузой перед self-check (см. ниже): текстовое поле с портами через запятую и чекбокс ICMP. +Порты `22` и `443` в этом списке трактуются особо: помимо базового +TCP-connect (`tcp-22`/`tcp-443`) пробер дополнительно выполняет настоящий +обмен SSH-банером (`ssh`) и настоящий TLS-хендшейк (`tls-443`) — +голого открытого TCP-порта недостаточно, чтобы считать SSH/HTTPS +рабочими. Отдельного переключателя для этих доп.проверок нет: они +включаются и выключаются вместе с самим портом в `ports`. Если на порту +22/443 у площадки на самом деле слушает что-то, кроме SSH/HTTPS, +доп.проверка будет закономерно проваливаться — заведите для такого сервиса +другой порт. + > Правки через `orchestrator.inbound_checks` в `control-api.yaml` тоже > поддерживаются, но только как bootstrap пустой базы данных при самом > первом старте — как только в БД есть эта настройка (а она появляется diff --git a/internal/checkrunner/tls.go b/internal/checkrunner/tls.go new file mode 100644 index 0000000..707e825 --- /dev/null +++ b/internal/checkrunner/tls.go @@ -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() + }) +} diff --git a/internal/checkrunner/tls_test.go b/internal/checkrunner/tls_test.go new file mode 100644 index 0000000..b65f2e0 --- /dev/null +++ b/internal/checkrunner/tls_test.go @@ -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") + } +} diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index bdd509e..83c2f53 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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}, }, diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 806d0e2..9083817 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -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 } diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 62751ee..45892f2 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -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) + } +} diff --git a/internal/probercore/probercore.go b/internal/probercore/probercore.go index 342e02e..cabc614 100644 --- a/internal/probercore/probercore.go +++ b/internal/probercore/probercore.go @@ -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 diff --git a/internal/probercore/probercore_test.go b/internal/probercore/probercore_test.go index 7e6d668..1ac6eb8 100644 --- a/internal/probercore/probercore_test.go +++ b/internal/probercore/probercore_test.go @@ -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) + } + } +}