diff --git a/checker/collect.go b/checker/collect.go index d7d3d08..87deb16 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -23,10 +23,13 @@ package checker import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" "net" + "sort" "strconv" "strings" "sync" @@ -116,6 +119,76 @@ func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any return data, nil } +// ShareKey implements sdk.ObservationSharer. An SSH probe (reachability, banner, +// KEX/host-key algorithm posture, host keys) depends only on the set of +// addresses and ports dialed and the probe knobs, never on which domain name +// points at the server: SSH has no SNI, so the same daemon answers identically +// behind every name. The declared SSHFP fingerprints are folded in because they +// live in the observation and drive the SSHFP-match rule — two services with the +// same endpoints but different SSHFP must not share a verdict. +// +// The host/Domain label is intentionally excluded, mirroring the ping checker's +// exclusion of "which domain the addresses belong to": it is a display label +// that does not change reachability, the negotiated algorithms, the host keys, +// or the SSHFP comparison. Inputs with no probable address return "" so the host +// falls back to the default per-target caching. +func (p *sshProvider) ShareKey(opts sdk.CheckerOptions) (string, error) { + server, err := resolveServer(opts) + if err != nil { + return "", nil + } + + apex := "" + if v, ok := sdk.GetOption[string](opts, OptionDomainName); ok { + apex = strings.TrimSuffix(v, ".") + } + subdomain := "" + if svc, ok := sdk.GetOption[happydns.ServiceMessage](opts, OptionService); ok { + subdomain = strings.TrimSuffix(svc.Domain, ".") + } + origin := sdk.JoinRelative(subdomain, apex) + _, ips := addressesFromServer(server, origin) + if len(ips) == 0 { + return "", nil + } + + ports := parsePorts(optString(opts, OptionPorts, "")) + if len(ports) == 0 { + ports = []uint16{DefaultSSHPort} + } + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + includeAuthProbe := sdk.GetBoolOption(opts, OptionIncludeAuthProbe, true) + + sortedIPs := append([]string(nil), ips...) + sort.Strings(sortedIPs) + + sortedPorts := append([]uint16(nil), ports...) + sort.Slice(sortedPorts, func(i, j int) bool { return sortedPorts[i] < sortedPorts[j] }) + portStrs := make([]string, len(sortedPorts)) + for i, p := range sortedPorts { + portStrs[i] = strconv.Itoa(int(p)) + } + + sshfp := sshfpFromServer(server) + fps := make([]string, 0, len(sshfp.Records)) + for _, r := range sshfp.Records { + fps = append(fps, fmt.Sprintf("%d:%d:%s", r.Algorithm, r.Type, r.Fingerprint)) + } + sort.Strings(fps) + + h := sha256.Sum256(fmt.Appendf(nil, "%d|%t|%s|%s|%s", + timeoutMs, includeAuthProbe, + strings.Join(sortedIPs, ","), + strings.Join(portStrs, ","), + strings.Join(fps, ","), + )) + return "ssh:" + hex.EncodeToString(h[:8]), nil +} + // resolveServer extracts the *abstract.Server payload from the options. // Two shapes are supported, same as the ping checker: // - "service": ServiceMessage (in-process plugin path, or HTTP after diff --git a/checker/sharekey_test.go b/checker/sharekey_test.go new file mode 100644 index 0000000..968f0ab --- /dev/null +++ b/checker/sharekey_test.go @@ -0,0 +1,115 @@ +package checker + +import ( + "encoding/json" + "net" + "strings" + "testing" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" + happydns "git.happydns.org/happyDomain/model" + "git.happydns.org/happyDomain/services/abstract" +) + +func serverOpts(t *testing.T, domain string, srv abstract.Server, extra map[string]any) sdk.CheckerOptions { + t.Helper() + raw, err := json.Marshal(srv) + if err != nil { + t.Fatal(err) + } + opts := sdk.CheckerOptions{ + OptionService: happydns.ServiceMessage{ + ServiceMeta: happydns.ServiceMeta{ + Type: "abstract.Server", + Domain: domain, + }, + Service: raw, + }, + OptionDomainName: "example.org", + } + for k, v := range extra { + opts[k] = v + } + return opts +} + +func TestShareKey_StableAndPrefixed(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + a, err := p.ShareKey(serverOpts(t, "host", srv, nil)) + if err != nil { + t.Fatal(err) + } + b, _ := p.ShareKey(serverOpts(t, "host", srv, nil)) + if a != b { + t.Fatalf("non-deterministic key: %q vs %q", a, b) + } + if !strings.HasPrefix(a, "ssh:") { + t.Fatalf("missing prefix: %q", a) + } +} + +// The host/Domain label must not change the key: the same server answers +// identically behind every name (SSH has no SNI). +func TestShareKey_IgnoresDomainLabel(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + a, _ := p.ShareKey(serverOpts(t, "alpha", srv, nil)) + b, _ := p.ShareKey(serverOpts(t, "beta", srv, nil)) + if a != b { + t.Fatalf("domain label must not affect the key: %q vs %q", a, b) + } +} + +func TestShareKey_DiffersByAddress(t *testing.T) { + p := &sshProvider{} + a, _ := p.ShareKey(serverOpts(t, "host", abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}}, nil)) + b, _ := p.ShareKey(serverOpts(t, "host", abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.2").To4()}}, nil)) + if a == b { + t.Fatalf("different addresses must yield different keys") + } +} + +func TestShareKey_DiffersByPortsAndKnobs(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + def, _ := p.ShareKey(serverOpts(t, "host", srv, nil)) + ports, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionPorts: "22,2222"})) + auth, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionIncludeAuthProbe: false})) + timeout, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionProbeTimeoutMs: float64(1000)})) + + for name, got := range map[string]string{"ports": ports, "authProbe": auth, "timeout": timeout} { + if got == def { + t.Fatalf("%s must affect the key", name) + } + } +} + +// SSHFP fingerprints live in the observation and drive the SSHFP-match rule, so +// they must be part of the key. +func TestShareKey_DiffersBySSHFP(t *testing.T) { + p := &sshProvider{} + bare := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + withFP := abstract.Server{ + A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}, + SSHFP: []*dns.SSHFP{{Algorithm: 4, Type: 2, FingerPrint: "abcdef"}}, + } + + a, _ := p.ShareKey(serverOpts(t, "host", bare, nil)) + b, _ := p.ShareKey(serverOpts(t, "host", withFP, nil)) + if a == b { + t.Fatalf("declared SSHFP must affect the key") + } +} + +func TestShareKey_EmptyWhenNoAddress(t *testing.T) { + p := &sshProvider{} + if sk, err := p.ShareKey(sdk.CheckerOptions{}); err != nil || sk != "" { + t.Fatalf("expected empty key, got %q (err=%v)", sk, err) + } +}