- Go 99.1%
- Makefile 0.5%
- Dockerfile 0.4%
Bound line reads with readLineLimited to prevent a peer from exhausting memory by withholding line terminators, wrap previously bare error returns for consistent context, surface XML decoder Skip errors, and replace the goto in the XMPP feature scan with a labeled break. New starttls_test.go exercises SMTP/IMAP/POP3/XMPP/LDAP success and not-advertised paths through net.Pipe-mocked servers. |
||
|---|---|---|
| checker | ||
| contract | ||
| plugin | ||
| .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.
Options
| Id | Type | Default | Description |
|---|---|---|---|
probeTimeoutMs |
number | 10000 | Per-endpoint dial + handshake timeout in ms. |
Running
# Plugin (loaded by happyDomain at startup)
make plugin
# Standalone HTTP server
make && ./checker-tls -listen :8080