164 lines
6.1 KiB
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
|
|
}
|