// Package contract defines the DiscoveryEntry schema that checker-tls // consumes. Producer checkers (checker-xmpp, checker-srv, checker-sip, …) // import this package to describe the TLS endpoints they want probed; the // SDK remains agnostic of TLS specifics because the payload is marshaled // here and carried as opaque bytes inside sdk.DiscoveryEntry. // // Stability: this package is the source of truth for the on-wire payload // under Type. Breaking changes must bump the version suffix on Type // (tls.endpoint.v1 → v2) and ship a new Go type; consumers that only // understand the previous version will then ignore the new entries // automatically. package contract import ( "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // Type is the value placed in sdk.DiscoveryEntry.Type for this contract. // Consumers match entries by comparing against this exact string. const Type = "tls.endpoint.v1" // TLSEndpoint describes a single (host, port) that checker-tls should probe, // optionally preceded by a protocol-specific STARTTLS upgrade. type TLSEndpoint struct { // Host is the target hostname. Consumers dial it directly; trailing // dots are tolerated. Host string `json:"host"` // Port is the TCP port number. Port uint16 `json:"port"` // SNI is the server name to use in the TLS handshake. Leave empty when // equal to Host; consumers substitute Host in that case. Set // explicitly when the upstream service is fronted by a name that does // not match Host (CDN, multi-tenant host, XMPP domain vs. SRV target). SNI string `json:"sni,omitempty"` // STARTTLS names the plaintext protocol that must be upgraded before // the TLS handshake. Empty means "speak TLS immediately after TCP // connect". Non-empty values follow the SRV service-name convention // (RFC 6335): "smtp", "submission", "imap", "pop3", "xmpp-client", // "xmpp-server", "ldap", "nntp", "ftp", "sieve", "postgres", … // // Unknown values are surfaced by checker-tls as a handshake_failed // issue; they do not prevent other endpoints from being probed. STARTTLS string `json:"starttls,omitempty"` // RequireSTARTTLS is meaningful only when STARTTLS is non-empty. It // distinguishes a mandatory upgrade (server absence = crit) from an // opportunistic one (server absence = warn). Producers set this from // protocol knowledge: XMPP mandates STARTTLS, SMTP 25 is opportunistic, // SMTP 587/submission mandates it, etc. RequireSTARTTLS bool `json:"require,omitempty"` } // Validate rejects endpoints that cannot be probed: empty Host or zero Port. // STARTTLS dialect is intentionally not checked here (the checker surfaces // unsupported dialects at runtime via the tls.starttls_dialect_supported // rule), and SNI defaults to Host downstream. func (ep TLSEndpoint) Validate() error { if strings.TrimSpace(strings.TrimSuffix(ep.Host, ".")) == "" { return fmt.Errorf("contract: TLSEndpoint.Host is required") } if ep.Port == 0 { return fmt.Errorf("contract: TLSEndpoint.Port must be 1-65535") } return nil } // NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref // derived from ep, and a marshaled Payload. The returned entry can be // returned as-is from a DiscoveryPublisher implementation. func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { if err := ep.Validate(); err != nil { return sdk.DiscoveryEntry{}, err } payload, err := json.Marshal(ep) if err != nil { return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) } return sdk.DiscoveryEntry{ Type: Type, Ref: Ref(ep), Payload: payload, }, nil } // Ref computes a stable identifier for ep. The output is deterministic: // two endpoints with the same Host, Port, effective SNI, STARTTLS protocol, // and RequireSTARTTLS flag yield the same Ref. It is meant to double as a // human-readable key in probe result maps. // // Format (length capped by hashing the long form): // // sha1(host|port|sni|starttls|require)[:16] // // A 16-hex-digit (8-byte) prefix of SHA-1 is sufficient for this use case: // we only need stability against the single (producer, target) space, // where collisions are vanishingly unlikely. func Ref(ep TLSEndpoint) string { sni := ep.SNI if sni == "" { sni = ep.Host } req := "0" if ep.RequireSTARTTLS { req = "1" } canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above return hex.EncodeToString(sum[:8]) } // ParseEntry decodes e into a TLSEndpoint. Returns an error when e.Type is // not Type or when e.Payload fails to unmarshal. func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) { if e.Type != Type { return TLSEndpoint{}, fmt.Errorf("contract: entry type %q does not match %q", e.Type, Type) } var ep TLSEndpoint if err := json.Unmarshal(e.Payload, &ep); err != nil { return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) } if err := ep.Validate(); err != nil { return TLSEndpoint{}, err } return ep, nil } // Entry pairs a decoded TLSEndpoint with the Ref that appeared on the wire. // The wire Ref is preserved verbatim rather than recomputed so consumers // always match the value observed downstream (in RelatedObservation / // probe map keys), even if a future version of this package changes Ref's // derivation. type Entry struct { Ref string Endpoint TLSEndpoint } // ParseEntries filters entries to those of Type and decodes each payload. // Entries of other types are ignored silently, they belong to other // contracts. Entries of this type whose Payload fails to unmarshal are // skipped and returned as warnings so a single malformed payload cannot // starve the checker of the rest of its workload. func ParseEntries(entries []sdk.DiscoveryEntry) (out []Entry, warnings []error) { for _, e := range entries { if e.Type != Type { continue } ep, err := ParseEntry(e) if err != nil { warnings = append(warnings, fmt.Errorf("ref=%q: %w", e.Ref, err)) continue } out = append(out, Entry{Ref: e.Ref, Endpoint: ep}) } return out, warnings }