checker: implement ShareKey to mutualise SSH probes across targets

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. Implement sdk.ObservationSharer
so the host can probe an address set once and serve every target (of the same
user) that points at it, instead of re-connecting per record.

The share key sorts the resolved addresses and ports and folds in the probe
timeout, the auth-probe flag, and the declared SSHFP fingerprints — the latter
live in the observation and drive the SSHFP-match rule, so 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 does not change
reachability, the negotiated algorithms, the host keys, or the SSHFP
comparison. Inputs with no probable address yield "" so the host falls back to
per-target caching.
This commit is contained in:
nemunaire 2026-06-18 14:14:31 +09:00
commit 258d799a97
2 changed files with 188 additions and 0 deletions

View file

@ -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