No description
  • Go 99.1%
  • Makefile 0.5%
  • Dockerfile 0.4%
Find a file
2026-04-30 08:36:38 +07:00
checker Add tlsenum package and add version/cipher enumeration into the checker 2026-04-29 13:35:29 +07:00
contract Harden contract validation, STARTTLS edge cases, and rule output 2026-04-26 19:55:44 +07:00
plugin fix: Implement CheckerDefinitionProvider on tlsProvider 2026-04-26 00:36:44 +07:00
tlsenum Add tlsenum package and add version/cipher enumeration into the checker 2026-04-29 13:35:29 +07:00
.gitignore Initial commit 2026-04-24 12:13:57 +07:00
Dockerfile docker: add HEALTHCHECK probing /health 2026-04-26 16:37:20 +07:00
go.mod Add tlsenum package and add version/cipher enumeration into the checker 2026-04-29 13:35:29 +07:00
go.sum Add tlsenum package and add version/cipher enumeration into the checker 2026-04-29 13:35:29 +07:00
LICENSE Initial commit 2026-04-24 12:13:57 +07:00
main.go Harden contract validation, STARTTLS edge cases, and rule output 2026-04-26 19:55:44 +07:00
Makefile Migrate to checker-sdk-go v1.3.0 with standalone build tag 2026-04-24 14:04:55 +07:00
NOTICE Initial commit 2026-04-24 12:13:57 +07:00
README.md Update rules section 2026-04-30 08:36:38 +07:00

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 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.

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