# checker-tls TLS posture checker for happyDomain. Consumes `DiscoveryEntry` records of type `tls.endpoint.v1` published by service checkers (xmpp, srv, caldav, carddav, …), performs a real TCP dial, optional protocol-specific STARTTLS upgrade, and TLS handshake on each, and exports per-endpoint posture under the observation key `tls_probes`. ## For producers: the `contract` package Service checkers that want TLS probing on the endpoints they discover should depend on `git.happydns.org/checker-tls/contract` and emit `DiscoveryEntry` records through it. The contract is a tiny producer↔consumer package; it has no dependency on checker-tls itself beyond the SDK. ```go import ( sdk "git.happydns.org/checker-sdk-go/checker" tlsct "git.happydns.org/checker-tls/contract" ) // DiscoverEntries is the sdk.DiscoveryPublisher hook on your provider. func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { d := data.(*XMPPData) var out []sdk.DiscoveryEntry for _, srv := range d.Client { e, err := tlsct.NewEntry(tlsct.TLSEndpoint{ Host: srv.Target, Port: srv.Port, SNI: d.Domain, // only when it differs from Host STARTTLS: "xmpp-client", RequireSTARTTLS: true, }) if err != nil { return nil, err } out = append(out, e) } return out, nil } ``` ### `TLSEndpoint` fields | Field | Meaning | | ----------------- | ----------------------------------------------------------------------- | | `Host` | Target hostname (trailing dot tolerated). | | `Port` | TCP port. | | `SNI` | TLS SNI; leave empty when equal to `Host`. | | `STARTTLS` | SRV service name of the upgrade protocol; empty for direct TLS. | | `RequireSTARTTLS` | `true` when absence of STARTTLS must be reported crit, not opportunistic. | ### Helpers | Symbol | Role | | ------------------------ | ------------------------------------------------------------------------------ | | `contract.Type` | The `DiscoveryEntry.Type` string (`"tls.endpoint.v1"`). | | `contract.NewEntry(ep)` | Builds a `DiscoveryEntry` with a deterministic `Ref` and marshaled payload. | | `contract.Ref(ep)` | Exposes the Ref derivation so producers can reference it ahead of time. | | `contract.ParseEntry(e)` | Decodes a single entry; errors on wrong `Type` or malformed payload. | | `contract.ParseEntries(s)` | Filters a slice to this contract, decodes each, returns warnings for the malformed. | The `Ref` is a deterministic 16-hex-digit hash of `(Host, Port, effective SNI, STARTTLS proto, RequireSTARTTLS)`. It appears as the key in `tls_probes.probes` and as the value consumers will see in any future `RelatedObservation.Ref` field. ### Supported STARTTLS protocols | `TLSEndpoint.STARTTLS` | Upgrade performed | | ---------------------- | ---------------------------------------------- | | *(empty)* | Direct TLS on connect | | `smtp` | ESMTP EHLO + STARTTLS (RFC 3207) | | `submission` | Same as `smtp` | | `imap` | IMAP CAPABILITY + STARTTLS (RFC 3501) | | `pop3` | POP3 CAPA + STLS (RFC 2595) | | `xmpp-client` | XMPP c2s stream + `` (RFC 6120) | | `xmpp-server` | XMPP s2s stream + `` (RFC 6120) | | any other value | Probe reports a `handshake_failed` issue | The protocol table lives in `checker/starttls.go`; new entries are added there, not in this repo's consumers. ### Versioning `DiscoveryEntry.Type` ends in `.v1`. If a future schema adds a field the consumer can't ignore safely, this package will introduce `tls.endpoint.v2` alongside a new `TLSEndpoint` type, and old consumers will silently skip v2 entries (they do not match the expected `Type`). ## Observation payload Observation data written under `tls_probes`: ```json { "probes": { "": { "host": "example.net", "port": 5222, "endpoint": "example.net:5222", "type": "starttls-xmpp-client", "sni": "example.net", "tls_version": "TLS1.3", "cipher_suite": "TLS_AES_128_GCM_SHA256", "hostname_match": true, "chain_valid": true, "not_after": "2026-07-01T00:00:00Z", "issuer": "Let's Encrypt", "issuer_dn": "CN=R3,O=Let's Encrypt,C=US", "issuer_aki": "142EB317B75856CBAE500940E61FAF9D8B14C2C6", "issues": [] } }, "collected_at": "2026-04-21T12:34:56Z" } ``` The map is keyed by `contract.Ref(ep)`, the same value the host exposes on the lineage side so that a consumer knows which probe corresponds to which entry it originally published. The `type` field inside each probe preserves the human-readable `"tls"` / `"starttls-"` shape for backward compatibility with existing downstream parsers. ## Issues reported - `tcp_unreachable`, dial failed. - `handshake_failed`, TLS handshake or STARTTLS upgrade failed. - `starttls_not_offered`, server didn't advertise STARTTLS. Severity is `crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise. - `chain_invalid`, leaf does not chain to a system-trusted root. - `hostname_mismatch`, cert SANs don't cover the SNI. - `expired` / `expiring_soon`, cert expiry posture. - `weak_tls_version`, negotiated TLS < 1.2. ## Options | Id | Type | Default | Description | | ---------------- | ------ | ------- | -------------------------------------------- | | `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. | ## For embedders: certificate-fetch helpers The `checker` package also exports a small, stable surface for hosts that want to reuse the dial/STARTTLS/handshake plumbing outside of a `Collect` cycle — typically an HTTP handler that prefills a TLSA editor from a live endpoint. ```go import tls "git.happydns.org/checker-tls/checker" starttls := req.STARTTLS if starttls == "" { starttls = tls.AutoSTARTTLS(req.Port) // well-known port → dialect } certs, err := tls.FetchChain(ctx, host, req.Port, starttls, 10*time.Second) if err != nil { return err } chain := tls.BuildChain(certs) // []tls.CertInfo, leaf first ``` | Symbol | Role | | ----------------- | ----------------------------------------------------------------------------------------------------- | | `FetchChain` | Dials, runs the STARTTLS upgrade if requested, and returns the peer `*x509.Certificate` chain (leaf first). Uses `InsecureSkipVerify` so the chain is returned even when PKIX would reject it — callers do their own validation. | | `BuildChain` | Projects an `[]*x509.Certificate` to `[]CertInfo`, with the four DANE/TLSA `(selector, matching_type)` hashes precomputed. Same projection `Collect` writes into observations. | | `AutoSTARTTLS` | Maps a well-known port (25, 110, 143, 389, 587, 5222) to the STARTTLS dialect `FetchChain` should drive. Returns `""` when no mapping applies. | | `CertInfo` | DANE-friendly per-certificate view: DN, expiry, DER, SPKI DER, and `(cert\|spki) × (sha256\|sha512)` hex digests. | These three helpers are part of the package's public contract: signatures will not change without a bump of the importing module's `go.mod`. ## Running ```bash # Plugin (loaded by happyDomain at startup) make plugin # Standalone HTTP server make && ./checker-tls -listen :8080 ```