- Go 99.1%
- Makefile 0.5%
- Dockerfile 0.4%
| checker | ||
| contract | ||
| plugin | ||
| tlsenum | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| Makefile | ||
| NOTICE | ||
| README.md | ||
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.
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:
{
"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 iscritwhenTLSEndpoint.RequireSTARTTLSistrue,warnotherwise.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.
Rules
| Code | Description | Severity |
|---|---|---|
tls.endpoints_discovered |
Verifies that at least one TLS endpoint has been discovered for this target. | INFO |
tls.reachability |
Verifies that every discovered TLS endpoint accepts a TCP connection. | CRITICAL |
tls.handshake |
Verifies the TLS handshake completes on every reachable endpoint. | CRITICAL |
tls.starttls_advertised |
Verifies that STARTTLS endpoints advertise the upgrade capability. | CRITICAL |
tls.starttls_dialect_supported |
Verifies that discovered STARTTLS dialects are implemented by the checker. | CRITICAL |
tls.peer_certificate_present |
Verifies the server presented a certificate during the TLS handshake. | CRITICAL |
tls.chain_validity |
Verifies the presented certificate chain validates against the system trust store. | CRITICAL |
tls.hostname_match |
Verifies the leaf certificate covers the probed hostname (SNI). | CRITICAL |
tls.expiry |
Flags expired or soon-to-expire leaf certificates. | CRITICAL |
tls.version |
Flags endpoints negotiating a TLS version below the recommended TLS 1.2. | WARNING |
tls.cipher_suite |
Reports the cipher suite negotiated on each endpoint. | INFO |
tls.enum.versions |
Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option). | WARNING |
tls.enum.ciphers |
Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES). | WARNING |
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.
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
# Plugin (loaded by happyDomain at startup)
make plugin
# Standalone HTTP server
make && ./checker-tls -listen :8080