From 03af5615aea7aa55ee8a6bcfb9b282fc26190a60 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 18 Jun 2026 14:14:22 +0900 Subject: [PATCH] checker: implement ShareKey to mutualise TLS probes across targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TLS probe result depends only on the set of endpoints actually dialed and the probe knobs, never on which domain or service published them: the observation is a map keyed by each endpoint's contract Ref (host|port|effective SNI|STARTTLS|require). Implement sdk.ObservationSharer so the host dials a host:port once and serves every target that resolves to the same endpoint set, instead of re-handshaking per record. This is the highest-value case among the checkers, since dane, xmpp, srv, dav, … all funnel their endpoints into this single checker. The share key sorts the endpoint Refs and folds in the probe timeout and the cipher-enumeration flag, since both change what is collected (a tighter timeout can fail a slow handshake; enumeration adds the Enum block). An empty or unparseable entry set yields "" so the host falls back to per-target caching. --- checker/collect.go | 44 ++++++++++++++++++++++ checker/sharekey_test.go | 81 ++++++++++++++++++++++++++++++++++++++++ go.mod | 6 ++- go.sum | 4 +- 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 checker/sharekey_test.go diff --git a/checker/collect.go b/checker/collect.go index d576fb3..b437d38 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -2,8 +2,12 @@ package checker import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "log" + "sort" + "strings" "sync" "time" @@ -83,3 +87,43 @@ dispatch: CollectedAt: time.Now(), }, nil } + +// ShareKey implements sdk.ObservationSharer. A TLS probe result depends only on +// the set of endpoints actually dialed and the probe knobs, never on which +// domain or service published them: the observation is a map keyed by each +// endpoint's contract Ref (host|port|effective SNI|STARTTLS|require), so two +// targets that resolve to the same endpoint set produce identical probes. This +// lets the host dial a host:port once and serve every target that points at it +// instead of re-handshaking per record — the highest-value case here, since +// dane, xmpp, srv, dav, … all funnel endpoints into this single checker. +// +// The probe timeout and cipher-enumeration flag are folded in because they +// change what is collected (a tighter timeout can fail a slow handshake; +// enumeration adds the Enum block). Inputs that yield no probable endpoint +// return "" so the host falls back to the default per-target caching. +func (p *tlsProvider) ShareKey(opts sdk.CheckerOptions) (string, error) { + raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints) + if !ok { + return "", nil + } + + entries, _ := contract.ParseEntries(raw) + if len(entries) == 0 { + return "", nil + } + + refs := make([]string, 0, len(entries)) + for _, e := range entries { + refs = append(refs, e.Ref) + } + sort.Strings(refs) + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false) + + h := sha256.Sum256(fmt.Appendf(nil, "%d|%t|%s", timeoutMs, enumerate, strings.Join(refs, ","))) + return "tls:" + hex.EncodeToString(h[:8]), nil +} diff --git a/checker/sharekey_test.go b/checker/sharekey_test.go new file mode 100644 index 0000000..eb2385f --- /dev/null +++ b/checker/sharekey_test.go @@ -0,0 +1,81 @@ +package checker + +import ( + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-tls/contract" +) + +func mustEntry(t *testing.T, ep contract.TLSEndpoint) sdk.DiscoveryEntry { + t.Helper() + e, err := contract.NewEntry(ep) + if err != nil { + t.Fatalf("NewEntry(%v): %v", ep, err) + } + return e +} + +func TestShareKey_StableRegardlessOfOrder(t *testing.T) { + p := &tlsProvider{} + e1 := mustEntry(t, contract.TLSEndpoint{Host: "a.example.net", Port: 443}) + e2 := mustEntry(t, contract.TLSEndpoint{Host: "b.example.net", Port: 25, STARTTLS: "smtp"}) + + a, err := p.ShareKey(sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{e1, e2}}) + if err != nil { + t.Fatal(err) + } + b, _ := p.ShareKey(sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{e2, e1}}) + if a != b { + t.Fatalf("key must be order-independent: %q vs %q", a, b) + } + if !strings.HasPrefix(a, "tls:") { + t.Fatalf("missing prefix: %q", a) + } +} + +func TestShareKey_DiffersByEndpointSet(t *testing.T) { + p := &tlsProvider{} + e1 := mustEntry(t, contract.TLSEndpoint{Host: "a.example.net", Port: 443}) + e2 := mustEntry(t, contract.TLSEndpoint{Host: "a.example.net", Port: 8443}) + + a, _ := p.ShareKey(sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{e1}}) + b, _ := p.ShareKey(sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{e2}}) + if a == b { + t.Fatalf("different endpoints must yield different keys") + } +} + +func TestShareKey_DiffersByProbeKnobs(t *testing.T) { + p := &tlsProvider{} + e1 := mustEntry(t, contract.TLSEndpoint{Host: "a.example.net", Port: 443}) + base := func(extra map[string]any) sdk.CheckerOptions { + o := sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{e1}} + for k, v := range extra { + o[k] = v + } + return o + } + + def, _ := p.ShareKey(base(nil)) + timeout, _ := p.ShareKey(base(map[string]any{OptionProbeTimeoutMs: float64(1000)})) + enum, _ := p.ShareKey(base(map[string]any{OptionEnumerateCiphers: true})) + + if def == timeout { + t.Fatalf("probe timeout must affect the key") + } + if def == enum { + t.Fatalf("cipher enumeration must affect the key") + } +} + +func TestShareKey_EmptyWhenNoEndpoints(t *testing.T) { + p := &tlsProvider{} + if sk, err := p.ShareKey(sdk.CheckerOptions{}); err != nil || sk != "" { + t.Fatalf("expected empty key, got %q (err=%v)", sk, err) + } + if sk, _ := p.ShareKey(sdk.CheckerOptions{OptionEndpoints: []sdk.DiscoveryEntry{}}); sk != "" { + t.Fatalf("expected empty key for empty entry set, got %q", sk) + } +} diff --git a/go.mod b/go.mod index c23c67d..e221a68 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,14 @@ module git.happydns.org/checker-tls go 1.25.0 -require git.happydns.org/checker-sdk-go v1.5.0 +require ( + git.happydns.org/checker-sdk-go v1.11.0 + github.com/refraction-networking/utls v1.8.2 +) require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/klauspost/compress v1.17.4 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index c811775..09888cb 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.11.0 h1:+hs8OpcgvRMAoWyfqxih/Q0KYYUI1R8oFxO4RKioMLk= +git.happydns.org/checker-sdk-go v1.11.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=