187 lines
7.9 KiB
Markdown
187 lines
7.9 KiB
Markdown
# 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
|
||
```
|