checker-tls/contract/contract.go

164 lines
6.1 KiB
Go

// 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
}