package checker import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log" "sort" "strings" "sync" "time" sdk "git.happydns.org/checker-sdk-go/checker" "git.happydns.org/checker-tls/contract" ) func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints) if !ok { return nil, fmt.Errorf("no discovery entries in options: did the host wire AutoFillDiscoveryEntries?") } timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) if timeoutMs <= 0 { timeoutMs = DefaultProbeTimeoutMs } timeout := time.Duration(timeoutMs) * time.Millisecond enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false) entries, warnings := contract.ParseEntries(raw) for _, w := range warnings { log.Printf("checker-tls: discarding malformed entry: %v", w) } // An empty entry set is not an error: it is the steady state on any // target where no producer has published yet, and the first run after // a fresh publication when the producer hasn't finished its own cycle. // The rule surfaces this as StatusUnknown rather than StatusError so a // freshly-enrolled domain doesn't flap red. if len(entries) == 0 { return &TLSData{Probes: map[string]TLSProbe{}, CollectedAt: time.Now()}, nil } probes := make(map[string]TLSProbe, len(entries)) var mu sync.Mutex var wg sync.WaitGroup sem := make(chan struct{}, MaxConcurrentProbes) dispatch: for _, e := range entries { select { case sem <- struct{}{}: case <-ctx.Done(): break dispatch } wg.Add(1) go func() { defer wg.Done() defer func() { <-sem }() pr := probe(ctx, e.Endpoint, timeout) log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q", pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error) if enumerate && pr.TLSHandshakeOK { enumRes, skipReason := enumerateEndpoint(ctx, e.Endpoint, enumerationBudget) switch { case enumRes != nil && enumRes.Skipped != "": pr.Enum = enumRes log.Printf("checker-tls: enum %s:%d → error: %s (duration=%dms)", pr.Host, pr.Port, enumRes.Skipped, enumRes.DurationMS) case enumRes != nil: pr.Enum = enumRes log.Printf("checker-tls: enum %s:%d → versions=%d duration=%dms", pr.Host, pr.Port, len(enumRes.Versions), enumRes.DurationMS) case skipReason != "": log.Printf("checker-tls: enum %s:%d → skipped: %s", pr.Host, pr.Port, skipReason) } } mu.Lock() probes[e.Ref] = pr mu.Unlock() }() } wg.Wait() return &TLSData{ Probes: probes, 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 }