checker-tls/README.md

187 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 + `<starttls/>` (RFC 6120) |
| `xmpp-server` | XMPP s2s stream + `<starttls/>` (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": {
"<ref>": {
"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-<proto>"` 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
```