From d61a81d51c28d71dfab379b19ac775acaef571c9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Apr 2026 21:50:49 +0700 Subject: [PATCH 01/16] Initial commit --- .gitignore | 2 + Dockerfile | 14 +++ LICENSE | 21 ++++ Makefile | 25 ++++ NOTICE | 26 +++++ README.md | 155 +++++++++++++++++++++++++ checker/collect.go | 63 ++++++++++ checker/definition.go | 52 +++++++++ checker/interactive.go | 128 +++++++++++++++++++++ checker/prober.go | 236 ++++++++++++++++++++++++++++++++++++++ checker/prober_test.go | 95 +++++++++++++++ checker/provider.go | 14 +++ checker/rule.go | 147 ++++++++++++++++++++++++ checker/starttls.go | 15 +++ checker/starttls_imap.go | 64 +++++++++++ checker/starttls_ldap.go | 142 +++++++++++++++++++++++ checker/starttls_pop3.go | 70 +++++++++++ checker/starttls_smtp.go | 86 ++++++++++++++ checker/starttls_xmpp.go | 86 ++++++++++++++ checker/types.go | 73 ++++++++++++ contract/contract.go | 143 +++++++++++++++++++++++ contract/contract_test.go | 106 +++++++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 23 ++++ plugin/plugin.go | 13 +++ 26 files changed, 1806 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/prober.go create mode 100644 checker/prober_test.go create mode 100644 checker/provider.go create mode 100644 checker/rule.go create mode 100644 checker/starttls.go create mode 100644 checker/starttls_imap.go create mode 100644 checker/starttls_ldap.go create mode 100644 checker/starttls_pop3.go create mode 100644 checker/starttls_smtp.go create mode 100644 checker/starttls_xmpp.go create mode 100644 checker/types.go create mode 100644 contract/contract.go create mode 100644 contract/contract_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fc3550 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-tls +checker-tls.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea99603 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . + +FROM scratch +COPY --from=builder /checker-tls /checker-tls +EXPOSE 8080 +ENTRYPOINT ["/checker-tls"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf1467e --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-tls +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ae61715 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-dummy +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f94547a --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# 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 + `` (RFC 6120) | +| `xmpp-server` | XMPP s2s stream + `` (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": { + "": { + "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-"` 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. | + +## Running + +```bash +# Plugin (loaded by happyDomain at startup) +make plugin + +# Standalone HTTP server +make && ./checker-tls -listen :8080 +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..ff8f60b --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,63 @@ +package checker + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-tls/contract" +) + +func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints) + if !ok { + return nil, fmt.Errorf("no discovery entries in options: did the host wire AutoFillDiscoveryEntries?") + } + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + timeout := time.Duration(timeoutMs) * time.Millisecond + + entries, warnings := contract.ParseEntries(raw) + for _, w := range warnings { + log.Printf("checker-tls: discarding malformed entry: %v", w) + } + // An empty entry set is not an error: it is the steady state on any + // target where no producer has published yet, and the first run after + // a fresh publication when the producer hasn't finished its own cycle. + // The rule surfaces this as StatusUnknown rather than StatusError so a + // freshly-enrolled domain doesn't flap red. + if len(entries) == 0 { + return &TLSData{Probes: map[string]TLSProbe{}, CollectedAt: time.Now()}, nil + } + + probes := make(map[string]TLSProbe, len(entries)) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, MaxConcurrentProbes) + for _, e := range entries { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + pr := probe(ctx, e.Endpoint, timeout) + log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q", + pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error) + mu.Lock() + probes[e.Ref] = pr + mu.Unlock() + }() + } + wg.Wait() + + return &TLSData{ + Probes: probes, + CollectedAt: time.Now(), + }, nil +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..31c996d --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,52 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version defaults to "built-in"; standalone and plugin builds override it via +// -ldflags "-X .../checker.Version=...". +var Version = "built-in" + +// Definition returns the CheckerDefinition for the TLS checker. +func (p *tlsProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "tls", + Name: "TLS", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyTLSProbes}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: OptionProbeTimeoutMs, + Type: "number", + Label: "Per-endpoint probe timeout (ms)", + Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.", + Default: float64(DefaultProbeTimeoutMs), + }, + }, + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: OptionEndpoints, + Label: "Discovery entries", + Description: "Entries published by other checkers for this domain; this checker decodes the tls.endpoint.v1 contract and ignores the rest.", + AutoFill: sdk.AutoFillDiscoveryEntries, + Hide: true, + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 6 * time.Hour, + Max: 7 * 24 * time.Hour, + Default: 24 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..85a7dc3 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,128 @@ +package checker + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-tls/contract" +) + +// starttlsChoices returns the STARTTLS protocols supported by starttlsUpgraders, +// sorted, with an empty first entry meaning "speak TLS immediately". +func starttlsChoices() []string { + protos := make([]string, 0, len(starttlsUpgraders)+1) + protos = append(protos, "") + for k := range starttlsUpgraders { + protos = append(protos, k) + } + sort.Strings(protos) + return protos +} + +// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs +// a producer checker would put into a contract.TLSEndpoint; a human fills +// them in directly when running the checker standalone. +func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "host", + Type: "string", + Label: "Host", + Description: "Hostname or IP to probe.", + Placeholder: "example.com", + Required: true, + }, + { + Id: "port", + Type: "uint", + Label: "Port", + Description: "TCP port of the TLS (or STARTTLS) endpoint.", + Default: float64(443), + Required: true, + }, + { + Id: "sni", + Type: "string", + Label: "SNI", + Description: "Server name for the TLS handshake. Leave empty to reuse Host.", + Placeholder: "(defaults to Host)", + }, + { + Id: "starttls", + Type: "string", + Label: "STARTTLS protocol", + Description: "Plaintext protocol to upgrade before the handshake. Leave empty for direct TLS.", + Choices: starttlsChoices(), + }, + { + Id: "require", + Type: "bool", + Label: "Require STARTTLS", + Description: "When checked, a server that does not advertise STARTTLS is reported critical instead of a warning.", + }, + { + Id: OptionProbeTimeoutMs, + Type: "uint", + Label: "Probe timeout (ms)", + Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on the endpoint.", + Default: float64(DefaultProbeTimeoutMs), + }, + } +} + +// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into +// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns +// CheckerOptions shaped as if a happyDomain host had auto-filled +// OptionEndpoints via AutoFillDiscoveryEntries. +func (p *tlsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + host := strings.TrimSpace(r.FormValue("host")) + if host == "" { + return nil, errors.New("host is required") + } + + portStr := strings.TrimSpace(r.FormValue("port")) + if portStr == "" { + return nil, errors.New("port is required") + } + port64, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || port64 == 0 { + return nil, fmt.Errorf("invalid port %q: must be 1-65535", portStr) + } + + starttls := strings.TrimSpace(r.FormValue("starttls")) + if starttls != "" { + if _, ok := starttlsUpgraders[starttls]; !ok { + return nil, fmt.Errorf("unsupported STARTTLS protocol %q", starttls) + } + } + + ep := contract.TLSEndpoint{ + Host: host, + Port: uint16(port64), + SNI: strings.TrimSpace(r.FormValue("sni")), + STARTTLS: starttls, + RequireSTARTTLS: r.FormValue("require") == "true", + } + + entry, err := contract.NewEntry(ep) + if err != nil { + return nil, fmt.Errorf("build discovery entry: %w", err) + } + + opts := sdk.CheckerOptions{ + OptionEndpoints: []sdk.DiscoveryEntry{entry}, + } + + if v := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + opts[OptionProbeTimeoutMs] = float64(n) + } + } + + return opts, nil +} diff --git a/checker/prober.go b/checker/prober.go new file mode 100644 index 0000000..3039ea1 --- /dev/null +++ b/checker/prober.go @@ -0,0 +1,236 @@ +package checker + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "git.happydns.org/checker-tls/contract" +) + +// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint. +// Observation consumers already parse this field in its "tls" / +// "starttls-" shape; the contract-level split of direct vs. +// STARTTLS is collapsed back here so the wire format of tls_probes +// stays unchanged. +func probeTypeString(ep contract.TLSEndpoint) string { + if ep.STARTTLS == "" { + return "tls" + } + return "starttls-" + ep.STARTTLS +} + +// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the +// given endpoint and returns a populated TLSProbe. It never returns an error: +// transport/handshake failures are recorded on the probe so the caller can +// still surface them in the report. +func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe { + start := time.Now() + host := strings.TrimSuffix(ep.Host, ".") + addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port))) + sni := ep.SNI + if sni == "" { + sni = host + } + + p := TLSProbe{ + Host: host, + Port: ep.Port, + Endpoint: addr, + Type: probeTypeString(ep), + SNI: sni, + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + d := &net.Dialer{} + conn, err := d.DialContext(dialCtx, "tcp", addr) + if err != nil { + p.Error = "dial: " + err.Error() + p.Issues = append(p.Issues, Issue{ + Code: "tcp_unreachable", + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err), + Fix: "Check DNS, firewall, and that the service listens on this port.", + }) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + defer conn.Close() + + if deadline, ok := dialCtx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + tlsConn, err := handshake(conn, ep, sni) + if err != nil { + p.Error = err.Error() + p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + defer tlsConn.Close() + + state := tlsConn.ConnectionState() + p.TLSVersion = tls.VersionName(state.Version) + p.CipherSuite = tls.CipherSuiteName(state.CipherSuite) + + if len(state.PeerCertificates) == 0 { + p.Issues = append(p.Issues, Issue{ + Code: "no_peer_cert", + Severity: SeverityCrit, + Message: "Server presented no certificate.", + }) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + + leaf := state.PeerCertificates[0] + p.NotAfter = leaf.NotAfter + p.Issuer = leaf.Issuer.CommonName + p.IssuerDN = leaf.Issuer.String() + if len(leaf.AuthorityKeyId) > 0 { + p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId)) + } + p.Subject = leaf.Subject.CommonName + p.DNSNames = append(p.DNSNames, leaf.DNSNames...) + + hostnameMatch := leaf.VerifyHostname(sni) == nil + p.HostnameMatch = &hostnameMatch + + // Chain verification against system roots, using intermediates presented + // by the server. We run this independently from Go's tls.Config + // verification so we can report a dedicated "chain invalid" issue rather + // than failing the whole handshake. + intermediates := x509.NewCertPool() + for _, c := range state.PeerCertificates[1:] { + intermediates.AddCert(c) + } + now := time.Now() + _, verifyErr := leaf.Verify(x509.VerifyOptions{ + DNSName: sni, + Intermediates: intermediates, + CurrentTime: now, + }) + chainValid := verifyErr == nil + p.ChainValid = &chainValid + if !chainValid { + msg := "Invalid certificate chain" + if verifyErr != nil { + msg = "Invalid certificate chain: " + verifyErr.Error() + } + p.Issues = append(p.Issues, Issue{ + Code: "chain_invalid", + Severity: SeverityCrit, + Message: msg, + Fix: "Serve the full intermediate chain and ensure the root is trusted.", + }) + } + if !hostnameMatch { + p.Issues = append(p.Issues, Issue{ + Code: "hostname_mismatch", + Severity: SeverityCrit, + Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")), + Fix: "Re-issue the certificate with a matching SAN.", + }) + } + if leaf.NotAfter.Before(now) { + p.Issues = append(p.Issues, Issue{ + Code: "expired", + Severity: SeverityCrit, + Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339), + Fix: "Renew the certificate.", + }) + } else if leaf.NotAfter.Sub(now) < 14*24*time.Hour { + p.Issues = append(p.Issues, Issue{ + Code: "expiring_soon", + Severity: SeverityWarn, + Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")", + Fix: "Renew before expiry.", + }) + } + if state.Version < tls.VersionTLS12 { + p.Issues = append(p.Issues, Issue{ + Code: "weak_tls_version", + Severity: SeverityWarn, + Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.", + Fix: "Disable TLS 1.0/1.1 on the server.", + }) + } + + p.ElapsedMS = time.Since(start).Milliseconds() + return p +} + +// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and +// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify +// the chain separately in probe so an invalid chain becomes a structured +// Issue rather than aborting the handshake. +func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { + cfg := &tls.Config{ + ServerName: sni, + InsecureSkipVerify: true, + } + + if ep.STARTTLS == "" { + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.Handshake(); err != nil { + return nil, fmt.Errorf("tls-handshake: %w", err) + } + return tlsConn, nil + } + + up, ok := starttlsUpgraders[ep.STARTTLS] + if !ok { + return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS) + } + if err := up(conn, sni); err != nil { + return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err) + } + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.Handshake(); err != nil { + return nil, fmt.Errorf("tls-handshake-after-starttls: %w", err) + } + return tlsConn, nil +} + +// classifyHandshakeError converts a dial/handshake error into a structured +// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic +// for some endpoints) from hard failures. +func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue { + msg := err.Error() + + if ep.STARTTLS != "" && isStartTLSUnsupported(err) { + sev := SeverityWarn + if ep.RequireSTARTTLS { + sev = SeverityCrit + } + return Issue{ + Code: "starttls_not_offered", + Severity: sev, + Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg), + Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.", + } + } + + return Issue{ + Code: "handshake_failed", + Severity: SeverityCrit, + Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg), + Fix: "Inspect the server's TLS configuration and certificate.", + } +} + +var errStartTLSNotOffered = errors.New("starttls not advertised by server") + +func isStartTLSUnsupported(err error) bool { + return errors.Is(err, errStartTLSNotOffered) +} diff --git a/checker/prober_test.go b/checker/prober_test.go new file mode 100644 index 0000000..4023e58 --- /dev/null +++ b/checker/prober_test.go @@ -0,0 +1,95 @@ +package checker + +import ( + "context" + "net" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "git.happydns.org/checker-tls/contract" +) + +func TestProbe_DirectTLS_OK(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + host, portStr, _ := net.SplitHostPort(u.Host) + port, _ := strconv.ParseUint(portStr, 10, 16) + + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: host, + Port: uint16(port), + SNI: host, + }, 5*time.Second) + + if probe.Error != "" { + t.Fatalf("unexpected error: %s", probe.Error) + } + if probe.TLSVersion == "" { + t.Errorf("expected TLSVersion, got empty") + } + if probe.CipherSuite == "" { + t.Errorf("expected CipherSuite, got empty") + } + if probe.ChainValid == nil || *probe.ChainValid { + t.Errorf("httptest self-signed chain should NOT be valid (chain_valid=%v)", probe.ChainValid) + } + if probe.HostnameMatch == nil { + t.Errorf("expected HostnameMatch to be populated") + } + if probe.NotAfter.IsZero() { + t.Errorf("expected NotAfter populated") + } +} + +func TestProbe_TCPUnreachable(t *testing.T) { + // Grab a free port then immediately close it so we know nothing listens. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().(*net.TCPAddr) + _ = l.Close() + + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: uint16(addr.Port), + }, 1*time.Second) + + if probe.Error == "" { + t.Errorf("expected an error for unreachable port") + } + if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" { + t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues) + } +} + +func TestProbe_UnsupportedStartTLSProto(t *testing.T) { + // Listen so the dial succeeds, but the type maps to an unknown proto. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go func() { + c, err := l.Accept() + if err == nil { + c.Close() + } + }() + + addr := l.Addr().(*net.TCPAddr) + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: uint16(addr.Port), + STARTTLS: "totallyfake", + }, 2*time.Second) + + if probe.Error == "" { + t.Errorf("expected handshake error for unsupported starttls protocol") + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..7a33d6c --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,14 @@ +package checker + +import sdk "git.happydns.org/checker-sdk-go/checker" + +// Provider returns a new TLS observation provider. +func Provider() sdk.ObservationProvider { + return &tlsProvider{} +} + +type tlsProvider struct{} + +func (p *tlsProvider) Key() sdk.ObservationKey { + return ObservationKeyTLSProbes +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..310c283 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,147 @@ +package checker + +import ( + "context" + "fmt" + "sort" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into +// a single status for this checker run. +func Rule() sdk.CheckRule { + return &tlsRule{} +} + +type tlsRule struct{} + +func (r *tlsRule) Name() string { return "tls_posture" } + +func (r *tlsRule) Description() string { + return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints" +} + +func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error { + return nil +} + +func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data TLSData + if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to read tls_probes: %v", err), + Code: "tls_observation_error", + }} + } + + // Steady state when no producer has published entries for this target + // yet (or when the last producer run cleared them). Report Unknown so + // we don't flap red during the eventual-consistency window between a + // fresh enrollment and the first producer cycle. + if len(data.Probes) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: "No TLS endpoints have been discovered for this target yet", + Code: "tls_no_endpoints", + }} + } + + refs := make([]string, 0, len(data.Probes)) + for ref := range data.Probes { + refs = append(refs, ref) + } + sort.Strings(refs) + + out := make([]sdk.CheckState, 0, len(refs)) + for _, ref := range refs { + p := data.Probes[ref] + out = append(out, evaluateProbe(p)) + } + return out +} + +// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the +// probed endpoint so the host can correlate states across runs and surface +// them per-target in the UI. Message describes the finding only -- the UI +// renders Subject separately. +func evaluateProbe(p TLSProbe) sdk.CheckState { + subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) + meta := map[string]any{ + "type": p.Type, + "host": p.Host, + "port": p.Port, + "sni": p.SNI, + "issues": len(p.Issues), + } + if p.TLSVersion != "" { + meta["tls_version"] = p.TLSVersion + } + if !p.NotAfter.IsZero() { + meta["not_after"] = p.NotAfter + } + + worst, critMsg, warnMsg := summarize(p.Issues) + switch worst { + case SeverityCrit: + return sdk.CheckState{ + Status: sdk.StatusCrit, + Message: critMsg, + Code: "tls_critical", + Subject: subject, + Meta: meta, + } + case SeverityWarn: + return sdk.CheckState{ + Status: sdk.StatusWarn, + Message: warnMsg, + Code: "tls_warning", + Subject: subject, + Meta: meta, + } + default: + msg := "TLS endpoint OK" + if p.TLSVersion != "" { + msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion) + } + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: msg, + Code: "tls_ok", + Subject: subject, + Meta: meta, + } + } +} + +// summarize walks the issues once and returns (worst severity, first +// critical message, first warning message). Picking the messages during the +// same pass avoids a second iteration in the caller. +func summarize(issues []Issue) (worst, firstCrit, firstWarn string) { + for _, is := range issues { + msg := is.Message + if msg == "" { + msg = is.Code + } + switch is.Severity { + case SeverityCrit: + worst = SeverityCrit + if firstCrit == "" { + firstCrit = msg + } + case SeverityWarn: + if worst == "" || worst == SeverityInfo { + worst = SeverityWarn + } + if firstWarn == "" { + firstWarn = msg + } + case SeverityInfo: + if worst == "" { + worst = SeverityInfo + } + } + } + return +} diff --git a/checker/starttls.go b/checker/starttls.go new file mode 100644 index 0000000..8fb1edd --- /dev/null +++ b/checker/starttls.go @@ -0,0 +1,15 @@ +package checker + +import "net" + +// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on +// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success +// the returned function returns nil; on failure it returns a descriptive +// error (wrap errStartTLSNotOffered when the server advertises no STARTTLS). +type starttlsUpgrader func(conn net.Conn, sni string) error + +var starttlsUpgraders = map[string]starttlsUpgrader{} + +func registerStartTLS(protocol string, upgrader starttlsUpgrader) { + starttlsUpgraders[protocol] = upgrader +} diff --git a/checker/starttls_imap.go b/checker/starttls_imap.go new file mode 100644 index 0000000..7a7a6fb --- /dev/null +++ b/checker/starttls_imap.go @@ -0,0 +1,64 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("imap", starttlsIMAP) +} + +// starttlsIMAP implements RFC 3501 STARTTLS. +func starttlsIMAP(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + if _, err := rw.ReadString('\n'); err != nil { + return fmt.Errorf("read greeting: %w", err) + } + + if _, err := rw.WriteString("A001 CAPABILITY\r\n"); err != nil { + return fmt.Errorf("write CAPABILITY: %w", err) + } + if err := rw.Flush(); err != nil { + return err + } + + supportsSTARTTLS := false + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPABILITY: %w", err) + } + if strings.Contains(strings.ToUpper(line), "STARTTLS") { + supportsSTARTTLS = true + } + if strings.HasPrefix(line, "A001 ") { + break + } + } + if !supportsSTARTTLS { + return fmt.Errorf("%w: IMAP CAPABILITY did not advertise STARTTLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read STARTTLS response: %w", err) + } + if strings.HasPrefix(line, "A002 OK") { + return nil + } + if strings.HasPrefix(line, "A002 ") { + return fmt.Errorf("server refused STARTTLS: %s", strings.TrimSpace(line)) + } + } +} diff --git a/checker/starttls_ldap.go b/checker/starttls_ldap.go new file mode 100644 index 0000000..30b47d3 --- /dev/null +++ b/checker/starttls_ldap.go @@ -0,0 +1,142 @@ +package checker + +import ( + "bufio" + "fmt" + "io" + "net" +) + +func init() { + registerStartTLS("ldap", starttlsLDAP) +} + +// starttlsLDAP implements RFC 2830 StartTLS. +// +// It sends a single ExtendedRequest with OID 1.3.6.1.4.1.1466.20037 on +// messageID=1 and reads back the ExtendedResponse. On resultCode 0 the +// connection is ready for the TLS handshake. On resultCode 2 +// (protocolError) or 53 (unwillingToPerform) we wrap errStartTLSNotOffered +// -- the server is reachable but cannot upgrade -- so the caller can surface +// that as a missing-STARTTLS issue rather than a handshake failure. +func starttlsLDAP(conn net.Conn, sni string) error { + // Fixed LDAPMessage: + // SEQUENCE { + // INTEGER messageID = 1, + // [APPLICATION 23] SEQUENCE { + // [0] OCTET STRING "1.3.6.1.4.1.1466.20037" + // } + // } + request := []byte{ + 0x30, 0x1d, + 0x02, 0x01, 0x01, + 0x77, 0x18, + 0x80, 0x16, + '1', '.', '3', '.', '6', '.', '1', '.', '4', '.', '1', '.', + '1', '4', '6', '6', '.', '2', '0', '0', '3', '7', + } + if _, err := conn.Write(request); err != nil { + return fmt.Errorf("write StartTLS request: %w", err) + } + + r := bufio.NewReader(conn) + + tag, err := r.ReadByte() + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if tag != 0x30 { + return fmt.Errorf("unexpected LDAP response tag 0x%02x", tag) + } + length, err := readBERLength(r) + if err != nil { + return fmt.Errorf("read response length: %w", err) + } + if length <= 0 || length > 4096 { + return fmt.Errorf("unreasonable LDAP response length %d", length) + } + body := make([]byte, length) + if _, err := io.ReadFull(r, body); err != nil { + return fmt.Errorf("read response body: %w", err) + } + + // messageID INTEGER -- skip. + pos := 0 + if pos >= len(body) || body[pos] != 0x02 { + return fmt.Errorf("expected INTEGER messageID") + } + pos++ + if pos >= len(body) { + return fmt.Errorf("truncated messageID length") + } + msgIDLen := int(body[pos]) + pos++ + pos += msgIDLen + if pos >= len(body) { + return fmt.Errorf("truncated LDAP response") + } + + // [APPLICATION 24] constructed = 0x78. + if body[pos] != 0x78 { + return fmt.Errorf("expected ExtendedResponse tag, got 0x%02x", body[pos]) + } + pos++ + // Skip extendedResp length (possibly multi-byte). + if pos >= len(body) { + return fmt.Errorf("truncated ExtendedResponse length") + } + if body[pos] < 0x80 { + pos++ + } else { + n := int(body[pos] & 0x7f) + pos += 1 + n + } + + // resultCode ENUMERATED (tag 0x0a). + if pos+2 > len(body) || body[pos] != 0x0a { + return fmt.Errorf("expected resultCode ENUMERATED") + } + rcLen := int(body[pos+1]) + if rcLen < 1 || pos+2+rcLen > len(body) { + return fmt.Errorf("invalid resultCode length %d", rcLen) + } + rc := 0 + for i := 0; i < rcLen; i++ { + rc = (rc << 8) | int(body[pos+2+i]) + } + switch rc { + case 0: + return nil + case 2, 53: + return fmt.Errorf("%w: LDAP StartTLS refused (resultCode=%d)", errStartTLSNotOffered, rc) + default: + return fmt.Errorf("server refused StartTLS (LDAP resultCode=%d)", rc) + } +} + +// readBERLength reads a definite-form BER length (short or long form). +func readBERLength(r *bufio.Reader) (int, error) { + b, err := r.ReadByte() + if err != nil { + return 0, err + } + if b < 0x80 { + return int(b), nil + } + n := int(b & 0x7f) + if n == 0 { + return 0, fmt.Errorf("indefinite-form length not supported") + } + if n > 4 { + return 0, fmt.Errorf("length octet count %d too large", n) + } + length := 0 + for i := 0; i < n; i++ { + bb, err := r.ReadByte() + if err != nil { + return 0, err + } + length = (length << 8) | int(bb) + } + return length, nil +} diff --git a/checker/starttls_pop3.go b/checker/starttls_pop3.go new file mode 100644 index 0000000..887933a --- /dev/null +++ b/checker/starttls_pop3.go @@ -0,0 +1,70 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("pop3", starttlsPOP3) +} + +// starttlsPOP3 implements RFC 2595 STLS. +func starttlsPOP3(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + greeting, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read greeting: %w", err) + } + if !strings.HasPrefix(greeting, "+OK") { + return fmt.Errorf("unexpected POP3 greeting: %s", strings.TrimSpace(greeting)) + } + + if _, err := rw.WriteString("CAPA\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + first, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPA: %w", err) + } + supportsSTLS := false + if strings.HasPrefix(first, "+OK") { + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPA body: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "." { + break + } + if strings.EqualFold(line, "STLS") { + supportsSTLS = true + } + } + } + if !supportsSTLS { + return fmt.Errorf("%w: POP3 CAPA did not advertise STLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("STLS\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + resp, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read STLS response: %w", err) + } + if !strings.HasPrefix(resp, "+OK") { + return fmt.Errorf("server refused STLS: %s", strings.TrimSpace(resp)) + } + return nil +} diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go new file mode 100644 index 0000000..39db327 --- /dev/null +++ b/checker/starttls_smtp.go @@ -0,0 +1,86 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("smtp", starttlsSMTP) + registerStartTLS("submission", starttlsSMTP) +} + +// starttlsSMTP implements ESMTP EHLO + STARTTLS (RFC 3207). +func starttlsSMTP(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + if err := readSMTPGreeting(rw.Reader); err != nil { + return fmt.Errorf("read greeting: %w", err) + } + + if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil { + return fmt.Errorf("write ehlo: %w", err) + } + if err := rw.Flush(); err != nil { + return fmt.Errorf("flush ehlo: %w", err) + } + lines, err := readSMTPResponse(rw.Reader) + if err != nil { + return fmt.Errorf("read ehlo: %w", err) + } + if !hasSTARTTLSExt(lines) { + return fmt.Errorf("%w: EHLO did not advertise STARTTLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("STARTTLS\r\n"); err != nil { + return fmt.Errorf("write starttls: %w", err) + } + if err := rw.Flush(); err != nil { + return fmt.Errorf("flush starttls: %w", err) + } + resp, err := readSMTPResponse(rw.Reader) + if err != nil { + return fmt.Errorf("read starttls: %w", err) + } + if len(resp) == 0 || !strings.HasPrefix(resp[0], "220") { + return fmt.Errorf("server refused STARTTLS: %s", strings.Join(resp, " / ")) + } + return nil +} + +func readSMTPGreeting(r *bufio.Reader) error { + _, err := readSMTPResponse(r) + return err +} + +// readSMTPResponse reads one multi-line SMTP response (lines with "NNN-" are +// continuation, "NNN " terminates). +func readSMTPResponse(r *bufio.Reader) ([]string, error) { + var out []string + for { + line, err := r.ReadString('\n') + if err != nil { + return out, err + } + line = strings.TrimRight(line, "\r\n") + out = append(out, line) + if len(line) < 4 || line[3] == ' ' { + return out, nil + } + } +} + +func hasSTARTTLSExt(lines []string) bool { + for _, l := range lines { + if len(l) < 4 { + continue + } + rest := strings.ToUpper(strings.TrimSpace(l[4:])) + if rest == "STARTTLS" || strings.HasPrefix(rest, "STARTTLS ") { + return true + } + } + return false +} diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go new file mode 100644 index 0000000..d810654 --- /dev/null +++ b/checker/starttls_xmpp.go @@ -0,0 +1,86 @@ +package checker + +import ( + "encoding/xml" + "fmt" + "io" + "net" +) + +func init() { + registerStartTLS("xmpp-client", starttlsXMPPClient) + registerStartTLS("xmpp-server", starttlsXMPPServer) +} + +// starttlsXMPPClient implements RFC 6120 STARTTLS for c2s streams. +func starttlsXMPPClient(conn net.Conn, sni string) error { + return starttlsXMPP(conn, sni, "jabber:client") +} + +// starttlsXMPPServer implements RFC 6120 STARTTLS for s2s streams. +func starttlsXMPPServer(conn net.Conn, sni string) error { + return starttlsXMPP(conn, sni, "jabber:server") +} + +func starttlsXMPP(conn net.Conn, sni, ns string) error { + header := fmt.Sprintf(``, ns, sni) + if _, err := io.WriteString(conn, header); err != nil { + return fmt.Errorf("write stream header: %w", err) + } + + dec := xml.NewDecoder(conn) + + // Read the inbound opening and its . + hasStartTLS := false + for { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("read stream features: %w", err) + } + if se, ok := tok.(xml.StartElement); ok { + if se.Name.Local == "features" { + // Scan features children. + for { + t2, err := dec.Token() + if err != nil { + return fmt.Errorf("read features body: %w", err) + } + switch ee := t2.(type) { + case xml.StartElement: + if ee.Name.Local == "starttls" { + hasStartTLS = true + } + _ = dec.Skip() + case xml.EndElement: + if ee.Name.Local == "features" { + goto doneFeatures + } + } + } + } + } + } +doneFeatures: + if !hasStartTLS { + return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered) + } + + if _, err := io.WriteString(conn, ``); err != nil { + return fmt.Errorf("write starttls: %w", err) + } + + for { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("read proceed: %w", err) + } + if se, ok := tok.(xml.StartElement); ok { + switch se.Name.Local { + case "proceed": + return nil + case "failure": + return fmt.Errorf("server refused STARTTLS ()") + } + } + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..470e825 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,73 @@ +// Package checker implements a TLS checker for happyDomain. See README for +// the payload shape and consumer contract. +package checker + +import "time" + +// ObservationKeyTLSProbes is the observation key this checker writes. +const ObservationKeyTLSProbes = "tls_probes" + +// Option ids on CheckerOptions. +const ( + OptionEndpoints = "endpoints" + OptionProbeTimeoutMs = "probeTimeoutMs" +) + +// Defaults shared between the definition's Default field and the runtime +// fallback when probeTimeoutMs is unset or invalid. +const ( + DefaultProbeTimeoutMs = 10000 + // MaxConcurrentProbes caps parallel probes per collect run to avoid + // exhausting file descriptors on domains with many endpoints. + MaxConcurrentProbes = 32 +) + +// Severity values used in Issue.Severity (lowercase, ascii). +const ( + SeverityCrit = "crit" + SeverityWarn = "warn" + SeverityInfo = "info" +) + +// TLSData is the full collected payload written under ObservationKeyTLSProbes. +type TLSData struct { + Probes map[string]TLSProbe `json:"probes"` + CollectedAt time.Time `json:"collected_at"` +} + +// TLSProbe captures the outcome of probing a single endpoint. Field names +// mirror what consumers already parse (checker-xmpp's tlsProbeView). +type TLSProbe struct { + Host string `json:"host"` + Port uint16 `json:"port"` + Endpoint string `json:"endpoint"` + Type string `json:"type"` + SNI string `json:"sni,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` + // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for + // matching the CCADB CAA Identifiers CSV "Subject" column when the AKI + // lookup misses. + IssuerDN string `json:"issuer_dn,omitempty"` + // IssuerAKI is the uppercase hex of the leaf's Authority Key Identifier + // extension (i.e. the issuer cert's SKI). This is the primary lookup key + // into the CCADB CAA Identifiers CSV ("Subject Key Identifier (Hex)"). + IssuerAKI string `json:"issuer_aki,omitempty"` + Subject string `json:"subject,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + Error string `json:"error,omitempty"` + Issues []Issue `json:"issues,omitempty"` +} + +// Issue is a single TLS finding surfaced to the consumer. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` +} diff --git a/contract/contract.go b/contract/contract.go new file mode 100644 index 0000000..bc038b0 --- /dev/null +++ b/contract/contract.go @@ -0,0 +1,143 @@ +// Package contract defines the DiscoveryEntry schema that checker-tls +// consumes. Producer checkers (checker-xmpp, checker-srv, checker-sip, …) +// import this package to describe the TLS endpoints they want probed; the +// SDK remains agnostic of TLS specifics because the payload is marshaled +// here and carried as opaque bytes inside sdk.DiscoveryEntry. +// +// Stability: this package is the source of truth for the on-wire payload +// under Type. Breaking changes must bump the version suffix on Type +// (tls.endpoint.v1 → v2) and ship a new Go type; consumers that only +// understand the previous version will then ignore the new entries +// automatically. +package contract + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Type is the value placed in sdk.DiscoveryEntry.Type for this contract. +// Consumers match entries by comparing against this exact string. +const Type = "tls.endpoint.v1" + +// TLSEndpoint describes a single (host, port) that checker-tls should probe, +// optionally preceded by a protocol-specific STARTTLS upgrade. +type TLSEndpoint struct { + // Host is the target hostname. Consumers dial it directly; trailing + // dots are tolerated. + Host string `json:"host"` + + // Port is the TCP port number. + Port uint16 `json:"port"` + + // SNI is the server name to use in the TLS handshake. Leave empty when + // equal to Host; consumers substitute Host in that case. Set + // explicitly when the upstream service is fronted by a name that does + // not match Host (CDN, multi-tenant host, XMPP domain vs. SRV target). + SNI string `json:"sni,omitempty"` + + // STARTTLS names the plaintext protocol that must be upgraded before + // the TLS handshake. Empty means "speak TLS immediately after TCP + // connect". Non-empty values follow the SRV service-name convention + // (RFC 6335): "smtp", "submission", "imap", "pop3", "xmpp-client", + // "xmpp-server", "ldap", "nntp", "ftp", "sieve", "postgres", … + // + // Unknown values are surfaced by checker-tls as a handshake_failed + // issue; they do not prevent other endpoints from being probed. + STARTTLS string `json:"starttls,omitempty"` + + // RequireSTARTTLS is meaningful only when STARTTLS is non-empty. It + // distinguishes a mandatory upgrade (server absence = crit) from an + // opportunistic one (server absence = warn). Producers set this from + // protocol knowledge: XMPP mandates STARTTLS, SMTP 25 is opportunistic, + // SMTP 587/submission mandates it, etc. + RequireSTARTTLS bool `json:"require,omitempty"` +} + +// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref +// derived from ep, and a marshaled Payload. The returned entry can be +// returned as-is from a DiscoveryPublisher implementation. +func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { + payload, err := json.Marshal(ep) + if err != nil { + return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) + } + return sdk.DiscoveryEntry{ + Type: Type, + Ref: Ref(ep), + Payload: payload, + }, nil +} + +// Ref computes a stable identifier for ep. The output is deterministic: +// two endpoints with the same Host, Port, effective SNI, STARTTLS protocol, +// and RequireSTARTTLS flag yield the same Ref. It is meant to double as a +// human-readable key in probe result maps. +// +// Format (length capped by hashing the long form): +// +// sha1(host|port|sni|starttls|require)[:16] +// +// A 16-hex-digit (8-byte) prefix of SHA-1 is sufficient for this use case: +// we only need stability against the single (producer, target) space, +// where collisions are vanishingly unlikely. +func Ref(ep TLSEndpoint) string { + sni := ep.SNI + if sni == "" { + sni = ep.Host + } + req := "0" + if ep.RequireSTARTTLS { + req = "1" + } + canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) + sum := sha1.Sum([]byte(canonical)) + return hex.EncodeToString(sum[:8]) +} + +// ParseEntry decodes e into a TLSEndpoint. Returns an error when e.Type is +// not Type or when e.Payload fails to unmarshal. +func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) { + if e.Type != Type { + return TLSEndpoint{}, fmt.Errorf("contract: entry type %q does not match %q", e.Type, Type) + } + var ep TLSEndpoint + if err := json.Unmarshal(e.Payload, &ep); err != nil { + return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) + } + return ep, nil +} + +// Entry pairs a decoded TLSEndpoint with the Ref that appeared on the wire. +// The wire Ref is preserved verbatim rather than recomputed so consumers +// always match the value observed downstream (in RelatedObservation / +// probe map keys), even if a future version of this package changes Ref's +// derivation. +type Entry struct { + Ref string + Endpoint TLSEndpoint +} + +// ParseEntries filters entries to those of Type and decodes each payload. +// Entries of other types are ignored silently — they belong to other +// contracts. Entries of this type whose Payload fails to unmarshal are +// skipped and returned as warnings so a single malformed payload cannot +// starve the checker of the rest of its workload. +func ParseEntries(entries []sdk.DiscoveryEntry) (out []Entry, warnings []error) { + for _, e := range entries { + if e.Type != Type { + continue + } + ep, err := ParseEntry(e) + if err != nil { + warnings = append(warnings, fmt.Errorf("ref=%q: %w", e.Ref, err)) + continue + } + out = append(out, Entry{Ref: e.Ref, Endpoint: ep}) + } + return out, warnings +} diff --git a/contract/contract_test.go b/contract/contract_test.go new file mode 100644 index 0000000..ce8a047 --- /dev/null +++ b/contract/contract_test.go @@ -0,0 +1,106 @@ +package contract + +import ( + "encoding/json" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestNewEntry_RoundTrip(t *testing.T) { + in := TLSEndpoint{ + Host: "jabber.example.net", + Port: 5222, + SNI: "example.net", + STARTTLS: "xmpp-client", + RequireSTARTTLS: true, + } + e, err := NewEntry(in) + if err != nil { + t.Fatalf("NewEntry: %v", err) + } + if e.Type != Type { + t.Errorf("Type = %q, want %q", e.Type, Type) + } + if e.Ref == "" { + t.Error("Ref is empty") + } + + out, err := ParseEntry(e) + if err != nil { + t.Fatalf("ParseEntry: %v", err) + } + if out != in { + t.Errorf("round-trip mismatch:\n got %+v\nwant %+v", out, in) + } +} + +func TestRef_StableAcrossCalls(t *testing.T) { + ep1 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"} + ep2 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"} + if Ref(ep1) != Ref(ep2) { + t.Error("Ref is not deterministic") + } +} + +func TestRef_SNIDefaultsToHost(t *testing.T) { + a := TLSEndpoint{Host: "example.net", Port: 443} + b := TLSEndpoint{Host: "example.net", Port: 443, SNI: "example.net"} + if Ref(a) != Ref(b) { + t.Errorf("Ref differs when SNI is omitted vs. explicit but equal: %s vs %s", Ref(a), Ref(b)) + } +} + +func TestRef_FieldsAffectOutput(t *testing.T) { + base := TLSEndpoint{Host: "example.net", Port: 443} + cases := []TLSEndpoint{ + {Host: "other.net", Port: 443}, + {Host: "example.net", Port: 8443}, + {Host: "example.net", Port: 443, SNI: "alt.example.net"}, + {Host: "example.net", Port: 443, STARTTLS: "smtp"}, + {Host: "example.net", Port: 443, STARTTLS: "smtp", RequireSTARTTLS: true}, + } + baseRef := Ref(base) + for i, c := range cases { + if Ref(c) == baseRef { + t.Errorf("case %d: expected Ref to change when a significant field changes, got %q", i, baseRef) + } + } +} + +func TestParseEntry_WrongType(t *testing.T) { + e := sdk.DiscoveryEntry{Type: "something.else.v1", Ref: "x", Payload: json.RawMessage(`{}`)} + if _, err := ParseEntry(e); err == nil { + t.Error("expected error on wrong type, got nil") + } +} + +func TestParseEntry_BadPayload(t *testing.T) { + e := sdk.DiscoveryEntry{Type: Type, Ref: "x", Payload: json.RawMessage(`not json`)} + if _, err := ParseEntry(e); err == nil { + t.Error("expected error on malformed payload, got nil") + } +} + +func TestParseEntries_FiltersAndAccumulatesWarnings(t *testing.T) { + good, err := NewEntry(TLSEndpoint{Host: "a", Port: 443}) + if err != nil { + t.Fatal(err) + } + bad := sdk.DiscoveryEntry{Type: Type, Ref: "bad", Payload: json.RawMessage(`not json`)} + foreign := sdk.DiscoveryEntry{Type: "other.v1", Ref: "f", Payload: json.RawMessage(`{}`)} + + entries, warnings := ParseEntries([]sdk.DiscoveryEntry{good, bad, foreign}) + if len(entries) != 1 { + t.Errorf("entries = %d, want 1", len(entries)) + } + if entries[0].Endpoint.Host != "a" { + t.Errorf("decoded host = %q, want %q", entries[0].Endpoint.Host, "a") + } + if entries[0].Ref != good.Ref { + t.Errorf("preserved ref = %q, want %q", entries[0].Ref, good.Ref) + } + if len(warnings) != 1 { + t.Errorf("warnings = %d, want 1 (one malformed payload of the correct type)", len(warnings)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..416eb47 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-tls + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v1.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..272600a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f781e37 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + tls.Version = Version + + server := sdk.NewServer(tls.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..6648452 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,13 @@ +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + tls.Version = Version + return tls.Definition(), tls.Provider(), nil +} From ccc5b0cd9856e6964e439ae39246c95d120b344b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Apr 2026 21:50:49 +0700 Subject: [PATCH 02/16] Initial commit --- .gitignore | 2 + Dockerfile | 14 +++ LICENSE | 21 ++++ Makefile | 25 ++++ NOTICE | 26 +++++ README.md | 155 +++++++++++++++++++++++++ checker/collect.go | 63 ++++++++++ checker/definition.go | 52 +++++++++ checker/interactive.go | 128 +++++++++++++++++++++ checker/prober.go | 236 ++++++++++++++++++++++++++++++++++++++ checker/prober_test.go | 95 +++++++++++++++ checker/provider.go | 14 +++ checker/rule.go | 147 ++++++++++++++++++++++++ checker/starttls.go | 15 +++ checker/starttls_imap.go | 64 +++++++++++ checker/starttls_ldap.go | 142 +++++++++++++++++++++++ checker/starttls_pop3.go | 70 +++++++++++ checker/starttls_smtp.go | 86 ++++++++++++++ checker/starttls_xmpp.go | 86 ++++++++++++++ checker/types.go | 73 ++++++++++++ contract/contract.go | 143 +++++++++++++++++++++++ contract/contract_test.go | 106 +++++++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 23 ++++ plugin/plugin.go | 13 +++ 26 files changed, 1806 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/prober.go create mode 100644 checker/prober_test.go create mode 100644 checker/provider.go create mode 100644 checker/rule.go create mode 100644 checker/starttls.go create mode 100644 checker/starttls_imap.go create mode 100644 checker/starttls_ldap.go create mode 100644 checker/starttls_pop3.go create mode 100644 checker/starttls_smtp.go create mode 100644 checker/starttls_xmpp.go create mode 100644 checker/types.go create mode 100644 contract/contract.go create mode 100644 contract/contract_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fc3550 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-tls +checker-tls.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea99603 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . + +FROM scratch +COPY --from=builder /checker-tls /checker-tls +EXPOSE 8080 +ENTRYPOINT ["/checker-tls"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf1467e --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-tls +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ae61715 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-dummy +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f94547a --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# 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 + `` (RFC 6120) | +| `xmpp-server` | XMPP s2s stream + `` (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": { + "": { + "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-"` 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. | + +## Running + +```bash +# Plugin (loaded by happyDomain at startup) +make plugin + +# Standalone HTTP server +make && ./checker-tls -listen :8080 +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..ff8f60b --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,63 @@ +package checker + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-tls/contract" +) + +func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints) + if !ok { + return nil, fmt.Errorf("no discovery entries in options: did the host wire AutoFillDiscoveryEntries?") + } + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + timeout := time.Duration(timeoutMs) * time.Millisecond + + entries, warnings := contract.ParseEntries(raw) + for _, w := range warnings { + log.Printf("checker-tls: discarding malformed entry: %v", w) + } + // An empty entry set is not an error: it is the steady state on any + // target where no producer has published yet, and the first run after + // a fresh publication when the producer hasn't finished its own cycle. + // The rule surfaces this as StatusUnknown rather than StatusError so a + // freshly-enrolled domain doesn't flap red. + if len(entries) == 0 { + return &TLSData{Probes: map[string]TLSProbe{}, CollectedAt: time.Now()}, nil + } + + probes := make(map[string]TLSProbe, len(entries)) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, MaxConcurrentProbes) + for _, e := range entries { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + pr := probe(ctx, e.Endpoint, timeout) + log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q", + pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error) + mu.Lock() + probes[e.Ref] = pr + mu.Unlock() + }() + } + wg.Wait() + + return &TLSData{ + Probes: probes, + CollectedAt: time.Now(), + }, nil +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..c2fb6f5 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,52 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version defaults to "built-in"; standalone and plugin builds override it via +// -ldflags "-X .../checker.Version=...". +var Version = "built-in" + +// Definition returns the CheckerDefinition for the TLS checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "tls", + Name: "TLS", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyTLSProbes}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: OptionProbeTimeoutMs, + Type: "number", + Label: "Per-endpoint probe timeout (ms)", + Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.", + Default: float64(DefaultProbeTimeoutMs), + }, + }, + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: OptionEndpoints, + Label: "Discovery entries", + Description: "Entries published by other checkers for this domain; this checker decodes the tls.endpoint.v1 contract and ignores the rest.", + AutoFill: sdk.AutoFillDiscoveryEntries, + Hide: true, + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 6 * time.Hour, + Max: 7 * 24 * time.Hour, + Default: 24 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..85a7dc3 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,128 @@ +package checker + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-tls/contract" +) + +// starttlsChoices returns the STARTTLS protocols supported by starttlsUpgraders, +// sorted, with an empty first entry meaning "speak TLS immediately". +func starttlsChoices() []string { + protos := make([]string, 0, len(starttlsUpgraders)+1) + protos = append(protos, "") + for k := range starttlsUpgraders { + protos = append(protos, k) + } + sort.Strings(protos) + return protos +} + +// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs +// a producer checker would put into a contract.TLSEndpoint; a human fills +// them in directly when running the checker standalone. +func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "host", + Type: "string", + Label: "Host", + Description: "Hostname or IP to probe.", + Placeholder: "example.com", + Required: true, + }, + { + Id: "port", + Type: "uint", + Label: "Port", + Description: "TCP port of the TLS (or STARTTLS) endpoint.", + Default: float64(443), + Required: true, + }, + { + Id: "sni", + Type: "string", + Label: "SNI", + Description: "Server name for the TLS handshake. Leave empty to reuse Host.", + Placeholder: "(defaults to Host)", + }, + { + Id: "starttls", + Type: "string", + Label: "STARTTLS protocol", + Description: "Plaintext protocol to upgrade before the handshake. Leave empty for direct TLS.", + Choices: starttlsChoices(), + }, + { + Id: "require", + Type: "bool", + Label: "Require STARTTLS", + Description: "When checked, a server that does not advertise STARTTLS is reported critical instead of a warning.", + }, + { + Id: OptionProbeTimeoutMs, + Type: "uint", + Label: "Probe timeout (ms)", + Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on the endpoint.", + Default: float64(DefaultProbeTimeoutMs), + }, + } +} + +// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into +// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns +// CheckerOptions shaped as if a happyDomain host had auto-filled +// OptionEndpoints via AutoFillDiscoveryEntries. +func (p *tlsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + host := strings.TrimSpace(r.FormValue("host")) + if host == "" { + return nil, errors.New("host is required") + } + + portStr := strings.TrimSpace(r.FormValue("port")) + if portStr == "" { + return nil, errors.New("port is required") + } + port64, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || port64 == 0 { + return nil, fmt.Errorf("invalid port %q: must be 1-65535", portStr) + } + + starttls := strings.TrimSpace(r.FormValue("starttls")) + if starttls != "" { + if _, ok := starttlsUpgraders[starttls]; !ok { + return nil, fmt.Errorf("unsupported STARTTLS protocol %q", starttls) + } + } + + ep := contract.TLSEndpoint{ + Host: host, + Port: uint16(port64), + SNI: strings.TrimSpace(r.FormValue("sni")), + STARTTLS: starttls, + RequireSTARTTLS: r.FormValue("require") == "true", + } + + entry, err := contract.NewEntry(ep) + if err != nil { + return nil, fmt.Errorf("build discovery entry: %w", err) + } + + opts := sdk.CheckerOptions{ + OptionEndpoints: []sdk.DiscoveryEntry{entry}, + } + + if v := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + opts[OptionProbeTimeoutMs] = float64(n) + } + } + + return opts, nil +} diff --git a/checker/prober.go b/checker/prober.go new file mode 100644 index 0000000..3039ea1 --- /dev/null +++ b/checker/prober.go @@ -0,0 +1,236 @@ +package checker + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "git.happydns.org/checker-tls/contract" +) + +// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint. +// Observation consumers already parse this field in its "tls" / +// "starttls-" shape; the contract-level split of direct vs. +// STARTTLS is collapsed back here so the wire format of tls_probes +// stays unchanged. +func probeTypeString(ep contract.TLSEndpoint) string { + if ep.STARTTLS == "" { + return "tls" + } + return "starttls-" + ep.STARTTLS +} + +// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the +// given endpoint and returns a populated TLSProbe. It never returns an error: +// transport/handshake failures are recorded on the probe so the caller can +// still surface them in the report. +func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe { + start := time.Now() + host := strings.TrimSuffix(ep.Host, ".") + addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port))) + sni := ep.SNI + if sni == "" { + sni = host + } + + p := TLSProbe{ + Host: host, + Port: ep.Port, + Endpoint: addr, + Type: probeTypeString(ep), + SNI: sni, + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + d := &net.Dialer{} + conn, err := d.DialContext(dialCtx, "tcp", addr) + if err != nil { + p.Error = "dial: " + err.Error() + p.Issues = append(p.Issues, Issue{ + Code: "tcp_unreachable", + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err), + Fix: "Check DNS, firewall, and that the service listens on this port.", + }) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + defer conn.Close() + + if deadline, ok := dialCtx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + tlsConn, err := handshake(conn, ep, sni) + if err != nil { + p.Error = err.Error() + p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + defer tlsConn.Close() + + state := tlsConn.ConnectionState() + p.TLSVersion = tls.VersionName(state.Version) + p.CipherSuite = tls.CipherSuiteName(state.CipherSuite) + + if len(state.PeerCertificates) == 0 { + p.Issues = append(p.Issues, Issue{ + Code: "no_peer_cert", + Severity: SeverityCrit, + Message: "Server presented no certificate.", + }) + p.ElapsedMS = time.Since(start).Milliseconds() + return p + } + + leaf := state.PeerCertificates[0] + p.NotAfter = leaf.NotAfter + p.Issuer = leaf.Issuer.CommonName + p.IssuerDN = leaf.Issuer.String() + if len(leaf.AuthorityKeyId) > 0 { + p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId)) + } + p.Subject = leaf.Subject.CommonName + p.DNSNames = append(p.DNSNames, leaf.DNSNames...) + + hostnameMatch := leaf.VerifyHostname(sni) == nil + p.HostnameMatch = &hostnameMatch + + // Chain verification against system roots, using intermediates presented + // by the server. We run this independently from Go's tls.Config + // verification so we can report a dedicated "chain invalid" issue rather + // than failing the whole handshake. + intermediates := x509.NewCertPool() + for _, c := range state.PeerCertificates[1:] { + intermediates.AddCert(c) + } + now := time.Now() + _, verifyErr := leaf.Verify(x509.VerifyOptions{ + DNSName: sni, + Intermediates: intermediates, + CurrentTime: now, + }) + chainValid := verifyErr == nil + p.ChainValid = &chainValid + if !chainValid { + msg := "Invalid certificate chain" + if verifyErr != nil { + msg = "Invalid certificate chain: " + verifyErr.Error() + } + p.Issues = append(p.Issues, Issue{ + Code: "chain_invalid", + Severity: SeverityCrit, + Message: msg, + Fix: "Serve the full intermediate chain and ensure the root is trusted.", + }) + } + if !hostnameMatch { + p.Issues = append(p.Issues, Issue{ + Code: "hostname_mismatch", + Severity: SeverityCrit, + Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")), + Fix: "Re-issue the certificate with a matching SAN.", + }) + } + if leaf.NotAfter.Before(now) { + p.Issues = append(p.Issues, Issue{ + Code: "expired", + Severity: SeverityCrit, + Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339), + Fix: "Renew the certificate.", + }) + } else if leaf.NotAfter.Sub(now) < 14*24*time.Hour { + p.Issues = append(p.Issues, Issue{ + Code: "expiring_soon", + Severity: SeverityWarn, + Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")", + Fix: "Renew before expiry.", + }) + } + if state.Version < tls.VersionTLS12 { + p.Issues = append(p.Issues, Issue{ + Code: "weak_tls_version", + Severity: SeverityWarn, + Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.", + Fix: "Disable TLS 1.0/1.1 on the server.", + }) + } + + p.ElapsedMS = time.Since(start).Milliseconds() + return p +} + +// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and +// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify +// the chain separately in probe so an invalid chain becomes a structured +// Issue rather than aborting the handshake. +func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { + cfg := &tls.Config{ + ServerName: sni, + InsecureSkipVerify: true, + } + + if ep.STARTTLS == "" { + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.Handshake(); err != nil { + return nil, fmt.Errorf("tls-handshake: %w", err) + } + return tlsConn, nil + } + + up, ok := starttlsUpgraders[ep.STARTTLS] + if !ok { + return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS) + } + if err := up(conn, sni); err != nil { + return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err) + } + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.Handshake(); err != nil { + return nil, fmt.Errorf("tls-handshake-after-starttls: %w", err) + } + return tlsConn, nil +} + +// classifyHandshakeError converts a dial/handshake error into a structured +// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic +// for some endpoints) from hard failures. +func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue { + msg := err.Error() + + if ep.STARTTLS != "" && isStartTLSUnsupported(err) { + sev := SeverityWarn + if ep.RequireSTARTTLS { + sev = SeverityCrit + } + return Issue{ + Code: "starttls_not_offered", + Severity: sev, + Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg), + Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.", + } + } + + return Issue{ + Code: "handshake_failed", + Severity: SeverityCrit, + Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg), + Fix: "Inspect the server's TLS configuration and certificate.", + } +} + +var errStartTLSNotOffered = errors.New("starttls not advertised by server") + +func isStartTLSUnsupported(err error) bool { + return errors.Is(err, errStartTLSNotOffered) +} diff --git a/checker/prober_test.go b/checker/prober_test.go new file mode 100644 index 0000000..4023e58 --- /dev/null +++ b/checker/prober_test.go @@ -0,0 +1,95 @@ +package checker + +import ( + "context" + "net" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "git.happydns.org/checker-tls/contract" +) + +func TestProbe_DirectTLS_OK(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + host, portStr, _ := net.SplitHostPort(u.Host) + port, _ := strconv.ParseUint(portStr, 10, 16) + + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: host, + Port: uint16(port), + SNI: host, + }, 5*time.Second) + + if probe.Error != "" { + t.Fatalf("unexpected error: %s", probe.Error) + } + if probe.TLSVersion == "" { + t.Errorf("expected TLSVersion, got empty") + } + if probe.CipherSuite == "" { + t.Errorf("expected CipherSuite, got empty") + } + if probe.ChainValid == nil || *probe.ChainValid { + t.Errorf("httptest self-signed chain should NOT be valid (chain_valid=%v)", probe.ChainValid) + } + if probe.HostnameMatch == nil { + t.Errorf("expected HostnameMatch to be populated") + } + if probe.NotAfter.IsZero() { + t.Errorf("expected NotAfter populated") + } +} + +func TestProbe_TCPUnreachable(t *testing.T) { + // Grab a free port then immediately close it so we know nothing listens. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().(*net.TCPAddr) + _ = l.Close() + + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: uint16(addr.Port), + }, 1*time.Second) + + if probe.Error == "" { + t.Errorf("expected an error for unreachable port") + } + if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" { + t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues) + } +} + +func TestProbe_UnsupportedStartTLSProto(t *testing.T) { + // Listen so the dial succeeds, but the type maps to an unknown proto. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go func() { + c, err := l.Accept() + if err == nil { + c.Close() + } + }() + + addr := l.Addr().(*net.TCPAddr) + probe := probe(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: uint16(addr.Port), + STARTTLS: "totallyfake", + }, 2*time.Second) + + if probe.Error == "" { + t.Errorf("expected handshake error for unsupported starttls protocol") + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..7a33d6c --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,14 @@ +package checker + +import sdk "git.happydns.org/checker-sdk-go/checker" + +// Provider returns a new TLS observation provider. +func Provider() sdk.ObservationProvider { + return &tlsProvider{} +} + +type tlsProvider struct{} + +func (p *tlsProvider) Key() sdk.ObservationKey { + return ObservationKeyTLSProbes +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..310c283 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,147 @@ +package checker + +import ( + "context" + "fmt" + "sort" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into +// a single status for this checker run. +func Rule() sdk.CheckRule { + return &tlsRule{} +} + +type tlsRule struct{} + +func (r *tlsRule) Name() string { return "tls_posture" } + +func (r *tlsRule) Description() string { + return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints" +} + +func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error { + return nil +} + +func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data TLSData + if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to read tls_probes: %v", err), + Code: "tls_observation_error", + }} + } + + // Steady state when no producer has published entries for this target + // yet (or when the last producer run cleared them). Report Unknown so + // we don't flap red during the eventual-consistency window between a + // fresh enrollment and the first producer cycle. + if len(data.Probes) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: "No TLS endpoints have been discovered for this target yet", + Code: "tls_no_endpoints", + }} + } + + refs := make([]string, 0, len(data.Probes)) + for ref := range data.Probes { + refs = append(refs, ref) + } + sort.Strings(refs) + + out := make([]sdk.CheckState, 0, len(refs)) + for _, ref := range refs { + p := data.Probes[ref] + out = append(out, evaluateProbe(p)) + } + return out +} + +// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the +// probed endpoint so the host can correlate states across runs and surface +// them per-target in the UI. Message describes the finding only -- the UI +// renders Subject separately. +func evaluateProbe(p TLSProbe) sdk.CheckState { + subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) + meta := map[string]any{ + "type": p.Type, + "host": p.Host, + "port": p.Port, + "sni": p.SNI, + "issues": len(p.Issues), + } + if p.TLSVersion != "" { + meta["tls_version"] = p.TLSVersion + } + if !p.NotAfter.IsZero() { + meta["not_after"] = p.NotAfter + } + + worst, critMsg, warnMsg := summarize(p.Issues) + switch worst { + case SeverityCrit: + return sdk.CheckState{ + Status: sdk.StatusCrit, + Message: critMsg, + Code: "tls_critical", + Subject: subject, + Meta: meta, + } + case SeverityWarn: + return sdk.CheckState{ + Status: sdk.StatusWarn, + Message: warnMsg, + Code: "tls_warning", + Subject: subject, + Meta: meta, + } + default: + msg := "TLS endpoint OK" + if p.TLSVersion != "" { + msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion) + } + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: msg, + Code: "tls_ok", + Subject: subject, + Meta: meta, + } + } +} + +// summarize walks the issues once and returns (worst severity, first +// critical message, first warning message). Picking the messages during the +// same pass avoids a second iteration in the caller. +func summarize(issues []Issue) (worst, firstCrit, firstWarn string) { + for _, is := range issues { + msg := is.Message + if msg == "" { + msg = is.Code + } + switch is.Severity { + case SeverityCrit: + worst = SeverityCrit + if firstCrit == "" { + firstCrit = msg + } + case SeverityWarn: + if worst == "" || worst == SeverityInfo { + worst = SeverityWarn + } + if firstWarn == "" { + firstWarn = msg + } + case SeverityInfo: + if worst == "" { + worst = SeverityInfo + } + } + } + return +} diff --git a/checker/starttls.go b/checker/starttls.go new file mode 100644 index 0000000..8fb1edd --- /dev/null +++ b/checker/starttls.go @@ -0,0 +1,15 @@ +package checker + +import "net" + +// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on +// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success +// the returned function returns nil; on failure it returns a descriptive +// error (wrap errStartTLSNotOffered when the server advertises no STARTTLS). +type starttlsUpgrader func(conn net.Conn, sni string) error + +var starttlsUpgraders = map[string]starttlsUpgrader{} + +func registerStartTLS(protocol string, upgrader starttlsUpgrader) { + starttlsUpgraders[protocol] = upgrader +} diff --git a/checker/starttls_imap.go b/checker/starttls_imap.go new file mode 100644 index 0000000..7a7a6fb --- /dev/null +++ b/checker/starttls_imap.go @@ -0,0 +1,64 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("imap", starttlsIMAP) +} + +// starttlsIMAP implements RFC 3501 STARTTLS. +func starttlsIMAP(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + if _, err := rw.ReadString('\n'); err != nil { + return fmt.Errorf("read greeting: %w", err) + } + + if _, err := rw.WriteString("A001 CAPABILITY\r\n"); err != nil { + return fmt.Errorf("write CAPABILITY: %w", err) + } + if err := rw.Flush(); err != nil { + return err + } + + supportsSTARTTLS := false + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPABILITY: %w", err) + } + if strings.Contains(strings.ToUpper(line), "STARTTLS") { + supportsSTARTTLS = true + } + if strings.HasPrefix(line, "A001 ") { + break + } + } + if !supportsSTARTTLS { + return fmt.Errorf("%w: IMAP CAPABILITY did not advertise STARTTLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read STARTTLS response: %w", err) + } + if strings.HasPrefix(line, "A002 OK") { + return nil + } + if strings.HasPrefix(line, "A002 ") { + return fmt.Errorf("server refused STARTTLS: %s", strings.TrimSpace(line)) + } + } +} diff --git a/checker/starttls_ldap.go b/checker/starttls_ldap.go new file mode 100644 index 0000000..30b47d3 --- /dev/null +++ b/checker/starttls_ldap.go @@ -0,0 +1,142 @@ +package checker + +import ( + "bufio" + "fmt" + "io" + "net" +) + +func init() { + registerStartTLS("ldap", starttlsLDAP) +} + +// starttlsLDAP implements RFC 2830 StartTLS. +// +// It sends a single ExtendedRequest with OID 1.3.6.1.4.1.1466.20037 on +// messageID=1 and reads back the ExtendedResponse. On resultCode 0 the +// connection is ready for the TLS handshake. On resultCode 2 +// (protocolError) or 53 (unwillingToPerform) we wrap errStartTLSNotOffered +// -- the server is reachable but cannot upgrade -- so the caller can surface +// that as a missing-STARTTLS issue rather than a handshake failure. +func starttlsLDAP(conn net.Conn, sni string) error { + // Fixed LDAPMessage: + // SEQUENCE { + // INTEGER messageID = 1, + // [APPLICATION 23] SEQUENCE { + // [0] OCTET STRING "1.3.6.1.4.1.1466.20037" + // } + // } + request := []byte{ + 0x30, 0x1d, + 0x02, 0x01, 0x01, + 0x77, 0x18, + 0x80, 0x16, + '1', '.', '3', '.', '6', '.', '1', '.', '4', '.', '1', '.', + '1', '4', '6', '6', '.', '2', '0', '0', '3', '7', + } + if _, err := conn.Write(request); err != nil { + return fmt.Errorf("write StartTLS request: %w", err) + } + + r := bufio.NewReader(conn) + + tag, err := r.ReadByte() + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if tag != 0x30 { + return fmt.Errorf("unexpected LDAP response tag 0x%02x", tag) + } + length, err := readBERLength(r) + if err != nil { + return fmt.Errorf("read response length: %w", err) + } + if length <= 0 || length > 4096 { + return fmt.Errorf("unreasonable LDAP response length %d", length) + } + body := make([]byte, length) + if _, err := io.ReadFull(r, body); err != nil { + return fmt.Errorf("read response body: %w", err) + } + + // messageID INTEGER -- skip. + pos := 0 + if pos >= len(body) || body[pos] != 0x02 { + return fmt.Errorf("expected INTEGER messageID") + } + pos++ + if pos >= len(body) { + return fmt.Errorf("truncated messageID length") + } + msgIDLen := int(body[pos]) + pos++ + pos += msgIDLen + if pos >= len(body) { + return fmt.Errorf("truncated LDAP response") + } + + // [APPLICATION 24] constructed = 0x78. + if body[pos] != 0x78 { + return fmt.Errorf("expected ExtendedResponse tag, got 0x%02x", body[pos]) + } + pos++ + // Skip extendedResp length (possibly multi-byte). + if pos >= len(body) { + return fmt.Errorf("truncated ExtendedResponse length") + } + if body[pos] < 0x80 { + pos++ + } else { + n := int(body[pos] & 0x7f) + pos += 1 + n + } + + // resultCode ENUMERATED (tag 0x0a). + if pos+2 > len(body) || body[pos] != 0x0a { + return fmt.Errorf("expected resultCode ENUMERATED") + } + rcLen := int(body[pos+1]) + if rcLen < 1 || pos+2+rcLen > len(body) { + return fmt.Errorf("invalid resultCode length %d", rcLen) + } + rc := 0 + for i := 0; i < rcLen; i++ { + rc = (rc << 8) | int(body[pos+2+i]) + } + switch rc { + case 0: + return nil + case 2, 53: + return fmt.Errorf("%w: LDAP StartTLS refused (resultCode=%d)", errStartTLSNotOffered, rc) + default: + return fmt.Errorf("server refused StartTLS (LDAP resultCode=%d)", rc) + } +} + +// readBERLength reads a definite-form BER length (short or long form). +func readBERLength(r *bufio.Reader) (int, error) { + b, err := r.ReadByte() + if err != nil { + return 0, err + } + if b < 0x80 { + return int(b), nil + } + n := int(b & 0x7f) + if n == 0 { + return 0, fmt.Errorf("indefinite-form length not supported") + } + if n > 4 { + return 0, fmt.Errorf("length octet count %d too large", n) + } + length := 0 + for i := 0; i < n; i++ { + bb, err := r.ReadByte() + if err != nil { + return 0, err + } + length = (length << 8) | int(bb) + } + return length, nil +} diff --git a/checker/starttls_pop3.go b/checker/starttls_pop3.go new file mode 100644 index 0000000..887933a --- /dev/null +++ b/checker/starttls_pop3.go @@ -0,0 +1,70 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("pop3", starttlsPOP3) +} + +// starttlsPOP3 implements RFC 2595 STLS. +func starttlsPOP3(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + greeting, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read greeting: %w", err) + } + if !strings.HasPrefix(greeting, "+OK") { + return fmt.Errorf("unexpected POP3 greeting: %s", strings.TrimSpace(greeting)) + } + + if _, err := rw.WriteString("CAPA\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + first, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPA: %w", err) + } + supportsSTLS := false + if strings.HasPrefix(first, "+OK") { + for { + line, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read CAPA body: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "." { + break + } + if strings.EqualFold(line, "STLS") { + supportsSTLS = true + } + } + } + if !supportsSTLS { + return fmt.Errorf("%w: POP3 CAPA did not advertise STLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("STLS\r\n"); err != nil { + return err + } + if err := rw.Flush(); err != nil { + return err + } + resp, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read STLS response: %w", err) + } + if !strings.HasPrefix(resp, "+OK") { + return fmt.Errorf("server refused STLS: %s", strings.TrimSpace(resp)) + } + return nil +} diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go new file mode 100644 index 0000000..39db327 --- /dev/null +++ b/checker/starttls_smtp.go @@ -0,0 +1,86 @@ +package checker + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func init() { + registerStartTLS("smtp", starttlsSMTP) + registerStartTLS("submission", starttlsSMTP) +} + +// starttlsSMTP implements ESMTP EHLO + STARTTLS (RFC 3207). +func starttlsSMTP(conn net.Conn, sni string) error { + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + if err := readSMTPGreeting(rw.Reader); err != nil { + return fmt.Errorf("read greeting: %w", err) + } + + if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil { + return fmt.Errorf("write ehlo: %w", err) + } + if err := rw.Flush(); err != nil { + return fmt.Errorf("flush ehlo: %w", err) + } + lines, err := readSMTPResponse(rw.Reader) + if err != nil { + return fmt.Errorf("read ehlo: %w", err) + } + if !hasSTARTTLSExt(lines) { + return fmt.Errorf("%w: EHLO did not advertise STARTTLS", errStartTLSNotOffered) + } + + if _, err := rw.WriteString("STARTTLS\r\n"); err != nil { + return fmt.Errorf("write starttls: %w", err) + } + if err := rw.Flush(); err != nil { + return fmt.Errorf("flush starttls: %w", err) + } + resp, err := readSMTPResponse(rw.Reader) + if err != nil { + return fmt.Errorf("read starttls: %w", err) + } + if len(resp) == 0 || !strings.HasPrefix(resp[0], "220") { + return fmt.Errorf("server refused STARTTLS: %s", strings.Join(resp, " / ")) + } + return nil +} + +func readSMTPGreeting(r *bufio.Reader) error { + _, err := readSMTPResponse(r) + return err +} + +// readSMTPResponse reads one multi-line SMTP response (lines with "NNN-" are +// continuation, "NNN " terminates). +func readSMTPResponse(r *bufio.Reader) ([]string, error) { + var out []string + for { + line, err := r.ReadString('\n') + if err != nil { + return out, err + } + line = strings.TrimRight(line, "\r\n") + out = append(out, line) + if len(line) < 4 || line[3] == ' ' { + return out, nil + } + } +} + +func hasSTARTTLSExt(lines []string) bool { + for _, l := range lines { + if len(l) < 4 { + continue + } + rest := strings.ToUpper(strings.TrimSpace(l[4:])) + if rest == "STARTTLS" || strings.HasPrefix(rest, "STARTTLS ") { + return true + } + } + return false +} diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go new file mode 100644 index 0000000..d810654 --- /dev/null +++ b/checker/starttls_xmpp.go @@ -0,0 +1,86 @@ +package checker + +import ( + "encoding/xml" + "fmt" + "io" + "net" +) + +func init() { + registerStartTLS("xmpp-client", starttlsXMPPClient) + registerStartTLS("xmpp-server", starttlsXMPPServer) +} + +// starttlsXMPPClient implements RFC 6120 STARTTLS for c2s streams. +func starttlsXMPPClient(conn net.Conn, sni string) error { + return starttlsXMPP(conn, sni, "jabber:client") +} + +// starttlsXMPPServer implements RFC 6120 STARTTLS for s2s streams. +func starttlsXMPPServer(conn net.Conn, sni string) error { + return starttlsXMPP(conn, sni, "jabber:server") +} + +func starttlsXMPP(conn net.Conn, sni, ns string) error { + header := fmt.Sprintf(``, ns, sni) + if _, err := io.WriteString(conn, header); err != nil { + return fmt.Errorf("write stream header: %w", err) + } + + dec := xml.NewDecoder(conn) + + // Read the inbound opening and its . + hasStartTLS := false + for { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("read stream features: %w", err) + } + if se, ok := tok.(xml.StartElement); ok { + if se.Name.Local == "features" { + // Scan features children. + for { + t2, err := dec.Token() + if err != nil { + return fmt.Errorf("read features body: %w", err) + } + switch ee := t2.(type) { + case xml.StartElement: + if ee.Name.Local == "starttls" { + hasStartTLS = true + } + _ = dec.Skip() + case xml.EndElement: + if ee.Name.Local == "features" { + goto doneFeatures + } + } + } + } + } + } +doneFeatures: + if !hasStartTLS { + return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered) + } + + if _, err := io.WriteString(conn, ``); err != nil { + return fmt.Errorf("write starttls: %w", err) + } + + for { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("read proceed: %w", err) + } + if se, ok := tok.(xml.StartElement); ok { + switch se.Name.Local { + case "proceed": + return nil + case "failure": + return fmt.Errorf("server refused STARTTLS ()") + } + } + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..470e825 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,73 @@ +// Package checker implements a TLS checker for happyDomain. See README for +// the payload shape and consumer contract. +package checker + +import "time" + +// ObservationKeyTLSProbes is the observation key this checker writes. +const ObservationKeyTLSProbes = "tls_probes" + +// Option ids on CheckerOptions. +const ( + OptionEndpoints = "endpoints" + OptionProbeTimeoutMs = "probeTimeoutMs" +) + +// Defaults shared between the definition's Default field and the runtime +// fallback when probeTimeoutMs is unset or invalid. +const ( + DefaultProbeTimeoutMs = 10000 + // MaxConcurrentProbes caps parallel probes per collect run to avoid + // exhausting file descriptors on domains with many endpoints. + MaxConcurrentProbes = 32 +) + +// Severity values used in Issue.Severity (lowercase, ascii). +const ( + SeverityCrit = "crit" + SeverityWarn = "warn" + SeverityInfo = "info" +) + +// TLSData is the full collected payload written under ObservationKeyTLSProbes. +type TLSData struct { + Probes map[string]TLSProbe `json:"probes"` + CollectedAt time.Time `json:"collected_at"` +} + +// TLSProbe captures the outcome of probing a single endpoint. Field names +// mirror what consumers already parse (checker-xmpp's tlsProbeView). +type TLSProbe struct { + Host string `json:"host"` + Port uint16 `json:"port"` + Endpoint string `json:"endpoint"` + Type string `json:"type"` + SNI string `json:"sni,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` + // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for + // matching the CCADB CAA Identifiers CSV "Subject" column when the AKI + // lookup misses. + IssuerDN string `json:"issuer_dn,omitempty"` + // IssuerAKI is the uppercase hex of the leaf's Authority Key Identifier + // extension (i.e. the issuer cert's SKI). This is the primary lookup key + // into the CCADB CAA Identifiers CSV ("Subject Key Identifier (Hex)"). + IssuerAKI string `json:"issuer_aki,omitempty"` + Subject string `json:"subject,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + Error string `json:"error,omitempty"` + Issues []Issue `json:"issues,omitempty"` +} + +// Issue is a single TLS finding surfaced to the consumer. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` +} diff --git a/contract/contract.go b/contract/contract.go new file mode 100644 index 0000000..bc038b0 --- /dev/null +++ b/contract/contract.go @@ -0,0 +1,143 @@ +// Package contract defines the DiscoveryEntry schema that checker-tls +// consumes. Producer checkers (checker-xmpp, checker-srv, checker-sip, …) +// import this package to describe the TLS endpoints they want probed; the +// SDK remains agnostic of TLS specifics because the payload is marshaled +// here and carried as opaque bytes inside sdk.DiscoveryEntry. +// +// Stability: this package is the source of truth for the on-wire payload +// under Type. Breaking changes must bump the version suffix on Type +// (tls.endpoint.v1 → v2) and ship a new Go type; consumers that only +// understand the previous version will then ignore the new entries +// automatically. +package contract + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Type is the value placed in sdk.DiscoveryEntry.Type for this contract. +// Consumers match entries by comparing against this exact string. +const Type = "tls.endpoint.v1" + +// TLSEndpoint describes a single (host, port) that checker-tls should probe, +// optionally preceded by a protocol-specific STARTTLS upgrade. +type TLSEndpoint struct { + // Host is the target hostname. Consumers dial it directly; trailing + // dots are tolerated. + Host string `json:"host"` + + // Port is the TCP port number. + Port uint16 `json:"port"` + + // SNI is the server name to use in the TLS handshake. Leave empty when + // equal to Host; consumers substitute Host in that case. Set + // explicitly when the upstream service is fronted by a name that does + // not match Host (CDN, multi-tenant host, XMPP domain vs. SRV target). + SNI string `json:"sni,omitempty"` + + // STARTTLS names the plaintext protocol that must be upgraded before + // the TLS handshake. Empty means "speak TLS immediately after TCP + // connect". Non-empty values follow the SRV service-name convention + // (RFC 6335): "smtp", "submission", "imap", "pop3", "xmpp-client", + // "xmpp-server", "ldap", "nntp", "ftp", "sieve", "postgres", … + // + // Unknown values are surfaced by checker-tls as a handshake_failed + // issue; they do not prevent other endpoints from being probed. + STARTTLS string `json:"starttls,omitempty"` + + // RequireSTARTTLS is meaningful only when STARTTLS is non-empty. It + // distinguishes a mandatory upgrade (server absence = crit) from an + // opportunistic one (server absence = warn). Producers set this from + // protocol knowledge: XMPP mandates STARTTLS, SMTP 25 is opportunistic, + // SMTP 587/submission mandates it, etc. + RequireSTARTTLS bool `json:"require,omitempty"` +} + +// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref +// derived from ep, and a marshaled Payload. The returned entry can be +// returned as-is from a DiscoveryPublisher implementation. +func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { + payload, err := json.Marshal(ep) + if err != nil { + return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) + } + return sdk.DiscoveryEntry{ + Type: Type, + Ref: Ref(ep), + Payload: payload, + }, nil +} + +// Ref computes a stable identifier for ep. The output is deterministic: +// two endpoints with the same Host, Port, effective SNI, STARTTLS protocol, +// and RequireSTARTTLS flag yield the same Ref. It is meant to double as a +// human-readable key in probe result maps. +// +// Format (length capped by hashing the long form): +// +// sha1(host|port|sni|starttls|require)[:16] +// +// A 16-hex-digit (8-byte) prefix of SHA-1 is sufficient for this use case: +// we only need stability against the single (producer, target) space, +// where collisions are vanishingly unlikely. +func Ref(ep TLSEndpoint) string { + sni := ep.SNI + if sni == "" { + sni = ep.Host + } + req := "0" + if ep.RequireSTARTTLS { + req = "1" + } + canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) + sum := sha1.Sum([]byte(canonical)) + return hex.EncodeToString(sum[:8]) +} + +// ParseEntry decodes e into a TLSEndpoint. Returns an error when e.Type is +// not Type or when e.Payload fails to unmarshal. +func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) { + if e.Type != Type { + return TLSEndpoint{}, fmt.Errorf("contract: entry type %q does not match %q", e.Type, Type) + } + var ep TLSEndpoint + if err := json.Unmarshal(e.Payload, &ep); err != nil { + return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) + } + return ep, nil +} + +// Entry pairs a decoded TLSEndpoint with the Ref that appeared on the wire. +// The wire Ref is preserved verbatim rather than recomputed so consumers +// always match the value observed downstream (in RelatedObservation / +// probe map keys), even if a future version of this package changes Ref's +// derivation. +type Entry struct { + Ref string + Endpoint TLSEndpoint +} + +// ParseEntries filters entries to those of Type and decodes each payload. +// Entries of other types are ignored silently — they belong to other +// contracts. Entries of this type whose Payload fails to unmarshal are +// skipped and returned as warnings so a single malformed payload cannot +// starve the checker of the rest of its workload. +func ParseEntries(entries []sdk.DiscoveryEntry) (out []Entry, warnings []error) { + for _, e := range entries { + if e.Type != Type { + continue + } + ep, err := ParseEntry(e) + if err != nil { + warnings = append(warnings, fmt.Errorf("ref=%q: %w", e.Ref, err)) + continue + } + out = append(out, Entry{Ref: e.Ref, Endpoint: ep}) + } + return out, warnings +} diff --git a/contract/contract_test.go b/contract/contract_test.go new file mode 100644 index 0000000..ce8a047 --- /dev/null +++ b/contract/contract_test.go @@ -0,0 +1,106 @@ +package contract + +import ( + "encoding/json" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestNewEntry_RoundTrip(t *testing.T) { + in := TLSEndpoint{ + Host: "jabber.example.net", + Port: 5222, + SNI: "example.net", + STARTTLS: "xmpp-client", + RequireSTARTTLS: true, + } + e, err := NewEntry(in) + if err != nil { + t.Fatalf("NewEntry: %v", err) + } + if e.Type != Type { + t.Errorf("Type = %q, want %q", e.Type, Type) + } + if e.Ref == "" { + t.Error("Ref is empty") + } + + out, err := ParseEntry(e) + if err != nil { + t.Fatalf("ParseEntry: %v", err) + } + if out != in { + t.Errorf("round-trip mismatch:\n got %+v\nwant %+v", out, in) + } +} + +func TestRef_StableAcrossCalls(t *testing.T) { + ep1 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"} + ep2 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"} + if Ref(ep1) != Ref(ep2) { + t.Error("Ref is not deterministic") + } +} + +func TestRef_SNIDefaultsToHost(t *testing.T) { + a := TLSEndpoint{Host: "example.net", Port: 443} + b := TLSEndpoint{Host: "example.net", Port: 443, SNI: "example.net"} + if Ref(a) != Ref(b) { + t.Errorf("Ref differs when SNI is omitted vs. explicit but equal: %s vs %s", Ref(a), Ref(b)) + } +} + +func TestRef_FieldsAffectOutput(t *testing.T) { + base := TLSEndpoint{Host: "example.net", Port: 443} + cases := []TLSEndpoint{ + {Host: "other.net", Port: 443}, + {Host: "example.net", Port: 8443}, + {Host: "example.net", Port: 443, SNI: "alt.example.net"}, + {Host: "example.net", Port: 443, STARTTLS: "smtp"}, + {Host: "example.net", Port: 443, STARTTLS: "smtp", RequireSTARTTLS: true}, + } + baseRef := Ref(base) + for i, c := range cases { + if Ref(c) == baseRef { + t.Errorf("case %d: expected Ref to change when a significant field changes, got %q", i, baseRef) + } + } +} + +func TestParseEntry_WrongType(t *testing.T) { + e := sdk.DiscoveryEntry{Type: "something.else.v1", Ref: "x", Payload: json.RawMessage(`{}`)} + if _, err := ParseEntry(e); err == nil { + t.Error("expected error on wrong type, got nil") + } +} + +func TestParseEntry_BadPayload(t *testing.T) { + e := sdk.DiscoveryEntry{Type: Type, Ref: "x", Payload: json.RawMessage(`not json`)} + if _, err := ParseEntry(e); err == nil { + t.Error("expected error on malformed payload, got nil") + } +} + +func TestParseEntries_FiltersAndAccumulatesWarnings(t *testing.T) { + good, err := NewEntry(TLSEndpoint{Host: "a", Port: 443}) + if err != nil { + t.Fatal(err) + } + bad := sdk.DiscoveryEntry{Type: Type, Ref: "bad", Payload: json.RawMessage(`not json`)} + foreign := sdk.DiscoveryEntry{Type: "other.v1", Ref: "f", Payload: json.RawMessage(`{}`)} + + entries, warnings := ParseEntries([]sdk.DiscoveryEntry{good, bad, foreign}) + if len(entries) != 1 { + t.Errorf("entries = %d, want 1", len(entries)) + } + if entries[0].Endpoint.Host != "a" { + t.Errorf("decoded host = %q, want %q", entries[0].Endpoint.Host, "a") + } + if entries[0].Ref != good.Ref { + t.Errorf("preserved ref = %q, want %q", entries[0].Ref, good.Ref) + } + if len(warnings) != 1 { + t.Errorf("warnings = %d, want 1 (one malformed payload of the correct type)", len(warnings)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..416eb47 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-tls + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v1.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..272600a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f781e37 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + tls.Version = Version + + server := sdk.NewServer(tls.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..6648452 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,13 @@ +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + tls.Version = Version + return tls.Definition(), tls.Provider(), nil +} From 17ecf3beb577ed559e96dd38caca80ab4605b586 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 19:05:25 +0700 Subject: [PATCH 03/16] Publish certificate chain data for DANE consumers Add Chain []CertInfo to TLSProbe, carrying per-cert DER and precomputed TLSA hashes (Cert/SPKI, SHA-256/SHA-512) plus the raw SPKI DER. This lets downstream checkers (checker-dane) perform TLSA matching against the observed chain without re-running a TLS handshake. --- checker/prober.go | 30 ++++++++++++++++++++++++++++++ checker/types.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/checker/prober.go b/checker/prober.go index 3039ea1..b528814 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -2,8 +2,11 @@ package checker import ( "context" + "crypto/sha256" + "crypto/sha512" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -15,6 +18,32 @@ import ( "git.happydns.org/checker-tls/contract" ) +// buildChain returns CertInfo for each cert presented by the server, in the +// order the server sent them (leaf first). SPKI is extracted from the parsed +// certificate's RawSubjectPublicKeyInfo so we hash exactly the DER bytes +// DANE selector 1 refers to (RFC 6698 §1.1.3). +func buildChain(certs []*x509.Certificate) []CertInfo { + out := make([]CertInfo, len(certs)) + for i, c := range certs { + certSum256 := sha256.Sum256(c.Raw) + certSum512 := sha512.Sum512(c.Raw) + spkiSum256 := sha256.Sum256(c.RawSubjectPublicKeyInfo) + spkiSum512 := sha512.Sum512(c.RawSubjectPublicKeyInfo) + out[i] = CertInfo{ + DERBase64: base64.StdEncoding.EncodeToString(c.Raw), + Subject: c.Subject.String(), + Issuer: c.Issuer.String(), + NotAfter: c.NotAfter, + CertSHA256: hex.EncodeToString(certSum256[:]), + CertSHA512: hex.EncodeToString(certSum512[:]), + SPKISHA256: hex.EncodeToString(spkiSum256[:]), + SPKISHA512: hex.EncodeToString(spkiSum512[:]), + SPKIDERBase64: base64.StdEncoding.EncodeToString(c.RawSubjectPublicKeyInfo), + } + } + return out +} + // probeTypeString renders the TLSProbe.Type string from a TLSEndpoint. // Observation consumers already parse this field in its "tls" / // "starttls-" shape; the contract-level split of direct vs. @@ -102,6 +131,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) } p.Subject = leaf.Subject.CommonName p.DNSNames = append(p.DNSNames, leaf.DNSNames...) + p.Chain = buildChain(state.PeerCertificates) hostnameMatch := leaf.VerifyHostname(sni) == nil p.HostnameMatch = &hostnameMatch diff --git a/checker/types.go b/checker/types.go index 470e825..58bd547 100644 --- a/checker/types.go +++ b/checker/types.go @@ -59,11 +59,48 @@ type TLSProbe struct { IssuerAKI string `json:"issuer_aki,omitempty"` Subject string `json:"subject,omitempty"` DNSNames []string `json:"dns_names,omitempty"` + // Chain carries one entry per certificate presented by the server + // (leaf first, then intermediates in order). Each entry precomputes + // the four TLSA selector×matching_type hashes plus the raw DER so + // DANE consumers can match without re-handshaking or re-parsing. + Chain []CertInfo `json:"chain,omitempty"` ElapsedMS int64 `json:"elapsed_ms,omitempty"` Error string `json:"error,omitempty"` Issues []Issue `json:"issues,omitempty"` } +// CertInfo describes one certificate in the presented chain together with +// pre-hashed forms suitable for DANE/TLSA matching (RFC 6698 §2.1). +// +// Hex fields are lowercase, matching the representation emitted by +// miekg/dns for TLSA RR Certificate fields. +type CertInfo struct { + // DERBase64 is the standard base64 encoding of the certificate's DER + // form. Carried so consumers can do matching-type 0 (Full) without + // requiring a precomputed "full" hash and for fallback inspection. + DERBase64 string `json:"der_base64,omitempty"` + + // Subject / Issuer are short human-readable DNs for the HTML report. + Subject string `json:"subject,omitempty"` + Issuer string `json:"issuer,omitempty"` + + // NotAfter is the certificate's expiry. Carried so editors can show + // "expires on …" without re-parsing the DER. + NotAfter time.Time `json:"not_after,omitempty"` + + // Selector 0 = full certificate. + CertSHA256 string `json:"cert_sha256,omitempty"` + CertSHA512 string `json:"cert_sha512,omitempty"` + + // Selector 1 = SubjectPublicKeyInfo. + SPKISHA256 string `json:"spki_sha256,omitempty"` + SPKISHA512 string `json:"spki_sha512,omitempty"` + + // SPKIDERBase64 lets consumers handle (selector=1, matching=0) without + // re-parsing the certificate. + SPKIDERBase64 string `json:"spki_der_base64,omitempty"` +} + // Issue is a single TLS finding surfaced to the consumer. type Issue struct { Code string `json:"code"` From 9c54f5b0fb97730606e2690e469b670b882cbd0f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 12:18:12 +0700 Subject: [PATCH 04/16] Expose FetchChain and BuildChain helpers for external callers --- checker/fetch.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 checker/fetch.go diff --git a/checker/fetch.go b/checker/fetch.go new file mode 100644 index 0000000..1cdd816 --- /dev/null +++ b/checker/fetch.go @@ -0,0 +1,108 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "strconv" + "strings" + "time" +) + +// FetchChain dials host:port, optionally upgrades the connection via STARTTLS, +// completes a TLS handshake (InsecureSkipVerify so callers receive the chain +// even when PKIX would reject it), and returns the peer certificates leaf +// first. +// +// starttls is the protocol name as registered (smtp, submission, imap, pop3, +// ldap, xmpp-client, ...); pass "" for direct TLS. AutoSTARTTLS provides +// well-known port defaults. +func FetchChain(ctx context.Context, host string, port uint16, starttls string, timeout time.Duration) ([]*x509.Certificate, error) { + host = strings.TrimSuffix(host, ".") + addr := net.JoinHostPort(host, strconv.Itoa(int(port))) + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("dial %s: %w", addr, err) + } + defer conn.Close() + if dl, ok := dialCtx.Deadline(); ok { + _ = conn.SetDeadline(dl) + } + + if starttls != "" { + up, ok := starttlsUpgraders[starttls] + if !ok { + return nil, fmt.Errorf("unsupported starttls protocol %q", starttls) + } + if err := up(conn, host); err != nil { + return nil, fmt.Errorf("starttls-%s: %w", starttls, err) + } + } + + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: host, + InsecureSkipVerify: true, + }) + if err := tlsConn.HandshakeContext(dialCtx); err != nil { + return nil, fmt.Errorf("tls handshake: %w", err) + } + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return nil, fmt.Errorf("server presented no certificate") + } + return state.PeerCertificates, nil +} + +// BuildChain produces a CertInfo per peer certificate (leaf first), with the +// four (selector, matching_type) DANE hash pairs precomputed. This is the +// same projection probe() applies internally; exported so HTTP handlers can +// reuse it without re-deriving the format. +func BuildChain(certs []*x509.Certificate) []CertInfo { + return buildChain(certs) +} + +// AutoSTARTTLS maps a well-known port to the STARTTLS dialect FetchChain +// should drive. Returns "" when the port has no auto-mapping (caller should +// then use direct TLS or pass an explicit dialect). +func AutoSTARTTLS(port uint16) string { + switch port { + case 25, 587: + return "smtp" + case 143: + return "imap" + case 110: + return "pop3" + case 389: + return "ldap" + case 5222: + return "xmpp-client" + } + return "" +} From 79782a49c45cd9248f4f06d253ddc3a03289485d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 13:14:17 +0700 Subject: [PATCH 05/16] Migrate to checker-sdk-go v1.3.0 with standalone build tag The SDK split the HTTP server scaffolding into the new checker-sdk-go/checker/server subpackage. Update main.go to import server and call server.New, and isolate the interactive form code behind the standalone build tag so plugin/builtin builds skip net/http entirely. --- Dockerfile | 2 +- Makefile | 7 +++++-- checker/interactive.go | 6 ++++-- go.mod | 2 +- go.sum | 4 ++-- main.go | 6 +++--- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index ea99603..716c7d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . FROM scratch COPY --from=builder /checker-tls /checker-tls diff --git a/Makefile b/Makefile index bf1467e..8ebb85f 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) -.PHONY: all plugin docker clean +.PHONY: all plugin docker test clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) docker: docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . +test: + go test -tags standalone ./... + clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/checker/interactive.go b/checker/interactive.go index 85a7dc3..048b7a8 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,3 +1,5 @@ +//go:build standalone + package checker import ( @@ -24,7 +26,7 @@ func starttlsChoices() []string { return protos } -// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs +// RenderForm satisfies server.Interactive. The fields mirror the inputs // a producer checker would put into a contract.TLSEndpoint; a human fills // them in directly when running the checker standalone. func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { @@ -75,7 +77,7 @@ func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into +// ParseForm satisfies server.Interactive. It turns the human inputs into // a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns // CheckerOptions shaped as if a happyDomain host had auto-filled // OptionEndpoints via AutoFillDiscoveryEntries. diff --git a/go.mod b/go.mod index 416eb47..e64fa23 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module git.happydns.org/checker-tls go 1.25.0 -require git.happydns.org/checker-sdk-go v1.2.0 +require git.happydns.org/checker-sdk-go v1.3.0 diff --git a/go.sum b/go.sum index 272600a..fe4952c 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= -git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= +git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go index f781e37..ae5167c 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ import ( "flag" "log" - sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-sdk-go/checker/server" tls "git.happydns.org/checker-tls/checker" ) @@ -16,8 +16,8 @@ func main() { flag.Parse() tls.Version = Version - server := sdk.NewServer(tls.Provider()) - if err := server.ListenAndServe(*listenAddr); err != nil { + srv := server.New(tls.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } From 5b71e85f494a3fc028bfc2eaface15fa9a154a52 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 17:43:36 +0700 Subject: [PATCH 06/16] Bump SDK to 1.4.0 --- checker/rule.go | 10 +++++----- checker/types.go | 14 +++++++------- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/checker/rule.go b/checker/rule.go index 310c283..4c3d105 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -69,11 +69,11 @@ func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts func evaluateProbe(p TLSProbe) sdk.CheckState { subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) meta := map[string]any{ - "type": p.Type, - "host": p.Host, - "port": p.Port, - "sni": p.SNI, - "issues": len(p.Issues), + "type": p.Type, + "host": p.Host, + "port": p.Port, + "sni": p.SNI, + "issues": len(p.Issues), } if p.TLSVersion != "" { meta["tls_version"] = p.TLSVersion diff --git a/checker/types.go b/checker/types.go index 58bd547..3509d23 100644 --- a/checker/types.go +++ b/checker/types.go @@ -56,17 +56,17 @@ type TLSProbe struct { // IssuerAKI is the uppercase hex of the leaf's Authority Key Identifier // extension (i.e. the issuer cert's SKI). This is the primary lookup key // into the CCADB CAA Identifiers CSV ("Subject Key Identifier (Hex)"). - IssuerAKI string `json:"issuer_aki,omitempty"` - Subject string `json:"subject,omitempty"` - DNSNames []string `json:"dns_names,omitempty"` + IssuerAKI string `json:"issuer_aki,omitempty"` + Subject string `json:"subject,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` // Chain carries one entry per certificate presented by the server // (leaf first, then intermediates in order). Each entry precomputes // the four TLSA selector×matching_type hashes plus the raw DER so // DANE consumers can match without re-handshaking or re-parsing. - Chain []CertInfo `json:"chain,omitempty"` - ElapsedMS int64 `json:"elapsed_ms,omitempty"` - Error string `json:"error,omitempty"` - Issues []Issue `json:"issues,omitempty"` + Chain []CertInfo `json:"chain,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + Error string `json:"error,omitempty"` + Issues []Issue `json:"issues,omitempty"` } // CertInfo describes one certificate in the presented chain together with diff --git a/go.mod b/go.mod index e64fa23..bde2901 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module git.happydns.org/checker-tls go 1.25.0 -require git.happydns.org/checker-sdk-go v1.3.0 +require git.happydns.org/checker-sdk-go v1.4.0 diff --git a/go.sum b/go.sum index fe4952c..072aab1 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= -git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= From 4177fcdc7bb90ac2c28922162c4ce495fe2e1bb5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Apr 2026 23:14:42 +0700 Subject: [PATCH 07/16] Split monolithic rule into per-test rules, collect gathers facts only --- README.md | 16 +-- checker/collect.go | 4 +- checker/definition.go | 4 +- checker/prober.go | 133 ++++++------------- checker/prober_test.go | 7 +- checker/rule.go | 175 +++++++++---------------- checker/rules_certificate.go | 233 ++++++++++++++++++++++++++++++++++ checker/rules_discovery.go | 34 +++++ checker/rules_handshake.go | 60 +++++++++ checker/rules_protocol.go | 105 +++++++++++++++ checker/rules_reachability.go | 48 +++++++ checker/rules_starttls.go | 108 ++++++++++++++++ checker/types.go | 92 +++++++++----- contract/contract.go | 2 +- 14 files changed, 760 insertions(+), 261 deletions(-) create mode 100644 checker/rules_certificate.go create mode 100644 checker/rules_discovery.go create mode 100644 checker/rules_handshake.go create mode 100644 checker/rules_protocol.go create mode 100644 checker/rules_reachability.go create mode 100644 checker/rules_starttls.go diff --git a/README.md b/README.md index f94547a..61d4e1b 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Observation data written under `tls_probes`: } ``` -The map is keyed by `contract.Ref(ep)` — the same value the host exposes +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. @@ -129,14 +129,14 @@ 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 +- `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. +- `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 diff --git a/checker/collect.go b/checker/collect.go index ff8f60b..8db0ddb 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -47,8 +47,8 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any defer wg.Done() defer func() { <-sem }() pr := probe(ctx, e.Endpoint, timeout) - log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q", - pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error) + log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q", + pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error) mu.Lock() probes[e.Ref] = pr mu.Unlock() diff --git a/checker/definition.go b/checker/definition.go index c2fb6f5..c60fdd0 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -40,9 +40,7 @@ func Definition() *sdk.CheckerDefinition { }, }, }, - Rules: []sdk.CheckRule{ - Rule(), - }, + Rules: Rules(), Interval: &sdk.CheckIntervalSpec{ Min: 6 * time.Hour, Max: 7 * 24 * time.Hour, diff --git a/checker/prober.go b/checker/prober.go index b528814..74a05ef 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -58,8 +58,11 @@ func probeTypeString(ep contract.TLSEndpoint) string { // probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the // given endpoint and returns a populated TLSProbe. It never returns an error: -// transport/handshake failures are recorded on the probe so the caller can -// still surface them in the report. +// transport/handshake failures are recorded on the probe as raw fields so +// rules can classify them. +// +// This function MUST NOT decide severity or pass/fail: it only gathers +// observation data. All judgement happens in CheckRules (see rules_*.go). func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe { start := time.Now() host := strings.TrimSuffix(ep.Host, ".") @@ -70,11 +73,13 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) } p := TLSProbe{ - Host: host, - Port: ep.Port, - Endpoint: addr, - Type: probeTypeString(ep), - SNI: sni, + Host: host, + Port: ep.Port, + Endpoint: addr, + Type: probeTypeString(ep), + SNI: sni, + RequireSTARTTLS: ep.RequireSTARTTLS, + STARTTLSDialect: ep.STARTTLS, } dialCtx, cancel := context.WithTimeout(ctx, timeout) @@ -83,13 +88,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) d := &net.Dialer{} conn, err := d.DialContext(dialCtx, "tcp", addr) if err != nil { + p.TCPError = err.Error() p.Error = "dial: " + err.Error() - p.Issues = append(p.Issues, Issue{ - Code: "tcp_unreachable", - Severity: SeverityCrit, - Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err), - Fix: "Check DNS, firewall, and that the service listens on this port.", - }) p.ElapsedMS = time.Since(start).Milliseconds() return p } @@ -101,23 +101,28 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) tlsConn, err := handshake(conn, ep, sni) if err != nil { + p.HandshakeError = err.Error() p.Error = err.Error() - p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) + if ep.STARTTLS != "" && isStartTLSUnsupported(err) { + p.STARTTLSNotOffered = true + } + if errors.Is(err, errUnsupportedStartTLSProto) { + p.STARTTLSUnsupportedProto = true + } p.ElapsedMS = time.Since(start).Milliseconds() return p } defer tlsConn.Close() + p.TLSHandshakeOK = true state := tlsConn.ConnectionState() + p.TLSVersionNum = state.Version p.TLSVersion = tls.VersionName(state.Version) p.CipherSuite = tls.CipherSuiteName(state.CipherSuite) + p.CipherSuiteID = state.CipherSuite if len(state.PeerCertificates) == 0 { - p.Issues = append(p.Issues, Issue{ - Code: "no_peer_cert", - Severity: SeverityCrit, - Message: "Server presented no certificate.", - }) + p.NoPeerCert = true p.ElapsedMS = time.Since(start).Milliseconds() return p } @@ -130,16 +135,16 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId)) } p.Subject = leaf.Subject.CommonName - p.DNSNames = append(p.DNSNames, leaf.DNSNames...) + p.DNSNames = leaf.DNSNames p.Chain = buildChain(state.PeerCertificates) hostnameMatch := leaf.VerifyHostname(sni) == nil p.HostnameMatch = &hostnameMatch // Chain verification against system roots, using intermediates presented - // by the server. We run this independently from Go's tls.Config - // verification so we can report a dedicated "chain invalid" issue rather - // than failing the whole handshake. + // by the server. Running it separately from tls.Config verification + // means we can record it as a raw observation rather than aborting the + // handshake, rules classify it afterwards. intermediates := x509.NewCertPool() for _, c := range state.PeerCertificates[1:] { intermediates.AddCert(c) @@ -152,48 +157,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) }) chainValid := verifyErr == nil p.ChainValid = &chainValid - if !chainValid { - msg := "Invalid certificate chain" - if verifyErr != nil { - msg = "Invalid certificate chain: " + verifyErr.Error() - } - p.Issues = append(p.Issues, Issue{ - Code: "chain_invalid", - Severity: SeverityCrit, - Message: msg, - Fix: "Serve the full intermediate chain and ensure the root is trusted.", - }) - } - if !hostnameMatch { - p.Issues = append(p.Issues, Issue{ - Code: "hostname_mismatch", - Severity: SeverityCrit, - Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")), - Fix: "Re-issue the certificate with a matching SAN.", - }) - } - if leaf.NotAfter.Before(now) { - p.Issues = append(p.Issues, Issue{ - Code: "expired", - Severity: SeverityCrit, - Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339), - Fix: "Renew the certificate.", - }) - } else if leaf.NotAfter.Sub(now) < 14*24*time.Hour { - p.Issues = append(p.Issues, Issue{ - Code: "expiring_soon", - Severity: SeverityWarn, - Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")", - Fix: "Renew before expiry.", - }) - } - if state.Version < tls.VersionTLS12 { - p.Issues = append(p.Issues, Issue{ - Code: "weak_tls_version", - Severity: SeverityWarn, - Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.", - Fix: "Disable TLS 1.0/1.1 on the server.", - }) + if verifyErr != nil { + p.ChainVerifyErr = verifyErr.Error() } p.ElapsedMS = time.Since(start).Milliseconds() @@ -202,8 +167,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) // handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and // then a TLS handshake. InsecureSkipVerify is true on purpose: we verify -// the chain separately in probe so an invalid chain becomes a structured -// Issue rather than aborting the handshake. +// the chain separately in probe so an invalid chain becomes a raw +// observation rather than aborting the handshake. func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { cfg := &tls.Config{ ServerName: sni, @@ -220,7 +185,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e up, ok := starttlsUpgraders[ep.STARTTLS] if !ok { - return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS) + return nil, fmt.Errorf("%w: %q", errUnsupportedStartTLSProto, ep.STARTTLS) } if err := up(conn, sni); err != nil { return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err) @@ -232,34 +197,10 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e return tlsConn, nil } -// classifyHandshakeError converts a dial/handshake error into a structured -// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic -// for some endpoints) from hard failures. -func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue { - msg := err.Error() - - if ep.STARTTLS != "" && isStartTLSUnsupported(err) { - sev := SeverityWarn - if ep.RequireSTARTTLS { - sev = SeverityCrit - } - return Issue{ - Code: "starttls_not_offered", - Severity: sev, - Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg), - Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.", - } - } - - return Issue{ - Code: "handshake_failed", - Severity: SeverityCrit, - Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg), - Fix: "Inspect the server's TLS configuration and certificate.", - } -} - -var errStartTLSNotOffered = errors.New("starttls not advertised by server") +var ( + errStartTLSNotOffered = errors.New("starttls not advertised by server") + errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol") +) func isStartTLSUnsupported(err error) bool { return errors.Is(err, errStartTLSNotOffered) diff --git a/checker/prober_test.go b/checker/prober_test.go index 4023e58..ad7e288 100644 --- a/checker/prober_test.go +++ b/checker/prober_test.go @@ -60,11 +60,8 @@ func TestProbe_TCPUnreachable(t *testing.T) { Port: uint16(addr.Port), }, 1*time.Second) - if probe.Error == "" { - t.Errorf("expected an error for unreachable port") - } - if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" { - t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues) + if probe.TCPError == "" { + t.Errorf("expected a TCP error for unreachable port") } } diff --git a/checker/rule.go b/checker/rule.go index 4c3d105..bcb1858 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -8,140 +8,81 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into -// a single status for this checker run. -func Rule() sdk.CheckRule { - return &tlsRule{} +// Rules returns the full list of CheckRules exposed by the TLS checker. +// Each rule covers a single concern (reachability, handshake, chain, hostname, +// expiry, TLS version, STARTTLS advertisement, cipher suite, …) so the UI can +// surface a passing-list rather than a single aggregated code. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &endpointsDiscoveredRule{}, + &reachabilityRule{}, + &tlsHandshakeRule{}, + &starttlsAdvertisedRule{}, + &starttlsSupportedRule{}, + &peerCertificateRule{}, + &chainValidityRule{}, + &hostnameMatchRule{}, + &expiryRule{}, + &tlsVersionRule{}, + &cipherSuiteRule{}, + } } -type tlsRule struct{} - -func (r *tlsRule) Name() string { return "tls_posture" } - -func (r *tlsRule) Description() string { - return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints" -} - -func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error { - return nil -} - -func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { +// loadData fetches the TLS observation. On error, returns a single error +// state the caller should emit. +func loadData(ctx context.Context, obs sdk.ObservationGetter) (*TLSData, *sdk.CheckState) { var data TLSData if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil { - return []sdk.CheckState{{ + return nil, &sdk.CheckState{ Status: sdk.StatusError, - Message: fmt.Sprintf("Failed to read tls_probes: %v", err), - Code: "tls_observation_error", - }} - } - - // Steady state when no producer has published entries for this target - // yet (or when the last producer run cleared them). Report Unknown so - // we don't flap red during the eventual-consistency window between a - // fresh enrollment and the first producer cycle. - if len(data.Probes) == 0 { - return []sdk.CheckState{{ - Status: sdk.StatusUnknown, - Message: "No TLS endpoints have been discovered for this target yet", - Code: "tls_no_endpoints", - }} + Message: fmt.Sprintf("failed to load tls_probes observation: %v", err), + Code: "tls.observation_error", + } } + return &data, nil +} +// sortedRefs returns the probe refs in deterministic order. Rules iterate +// this sorted list so CheckState output is stable. +func sortedRefs(data *TLSData) []string { refs := make([]string, 0, len(data.Probes)) for ref := range data.Probes { refs = append(refs, ref) } sort.Strings(refs) - - out := make([]sdk.CheckState, 0, len(refs)) - for _, ref := range refs { - p := data.Probes[ref] - out = append(out, evaluateProbe(p)) - } - return out + return refs } -// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the -// probed endpoint so the host can correlate states across runs and surface -// them per-target in the UI. Message describes the finding only -- the UI -// renders Subject separately. -func evaluateProbe(p TLSProbe) sdk.CheckState { - subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) - meta := map[string]any{ - "type": p.Type, - "host": p.Host, - "port": p.Port, - "sni": p.SNI, - "issues": len(p.Issues), +// subjectOf formats the UI-facing subject for a single probe. +func subjectOf(p TLSProbe) string { + return fmt.Sprintf("%s://%s", p.Type, p.Endpoint) +} + +// metaOf returns a compact meta map to attach to a CheckState. +func metaOf(p TLSProbe) map[string]any { + m := map[string]any{ + "type": p.Type, + "host": p.Host, + "port": p.Port, + "sni": p.SNI, } if p.TLSVersion != "" { - meta["tls_version"] = p.TLSVersion - } - if !p.NotAfter.IsZero() { - meta["not_after"] = p.NotAfter - } - - worst, critMsg, warnMsg := summarize(p.Issues) - switch worst { - case SeverityCrit: - return sdk.CheckState{ - Status: sdk.StatusCrit, - Message: critMsg, - Code: "tls_critical", - Subject: subject, - Meta: meta, - } - case SeverityWarn: - return sdk.CheckState{ - Status: sdk.StatusWarn, - Message: warnMsg, - Code: "tls_warning", - Subject: subject, - Meta: meta, - } - default: - msg := "TLS endpoint OK" - if p.TLSVersion != "" { - msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion) - } - return sdk.CheckState{ - Status: sdk.StatusOK, - Message: msg, - Code: "tls_ok", - Subject: subject, - Meta: meta, - } + m["tls_version"] = p.TLSVersion } + return m } -// summarize walks the issues once and returns (worst severity, first -// critical message, first warning message). Picking the messages during the -// same pass avoids a second iteration in the caller. -func summarize(issues []Issue) (worst, firstCrit, firstWarn string) { - for _, is := range issues { - msg := is.Message - if msg == "" { - msg = is.Code - } - switch is.Severity { - case SeverityCrit: - worst = SeverityCrit - if firstCrit == "" { - firstCrit = msg - } - case SeverityWarn: - if worst == "" || worst == SeverityInfo { - worst = SeverityWarn - } - if firstWarn == "" { - firstWarn = msg - } - case SeverityInfo: - if worst == "" { - worst = SeverityInfo - } - } - } - return +// passState / infoState / unknownState helpers. +func passState(code, message string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: message} +} +func unknownState(code, message string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: message} +} + +// emptyCaseState returns a single state describing "no probes to evaluate". +// Rules call this when len(data.Probes) == 0 to avoid returning an empty +// slice (see CheckRule.Evaluate contract). +func emptyCaseState(code string) sdk.CheckState { + return unknownState(code, "No TLS endpoints have been discovered for this target yet.") } diff --git a/checker/rules_certificate.go b/checker/rules_certificate.go new file mode 100644 index 0000000..2fc2f4c --- /dev/null +++ b/checker/rules_certificate.go @@ -0,0 +1,233 @@ +package checker + +import ( + "context" + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// peerCertificateRule flags successful handshakes in which the server sent +// no certificate. This is distinct from chain validity: if no cert was sent, +// hostname/chain/expiry cannot be evaluated. +type peerCertificateRule struct{} + +func (r *peerCertificateRule) Name() string { return "tls.peer_certificate_present" } +func (r *peerCertificateRule) Description() string { + return "Verifies the server presented a certificate during the TLS handshake." +} + +func (r *peerCertificateRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.peer_certificate_present.no_endpoints")} + } + + var out []sdk.CheckState + anyHandshake := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if !p.TLSHandshakeOK { + continue + } + anyHandshake = true + if !p.NoPeerCert { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.peer_certificate_present.missing", + Subject: subjectOf(p), + Message: fmt.Sprintf("Server on %s completed the handshake but presented no certificate.", p.Endpoint), + Meta: metaOf(p), + }) + } + if !anyHandshake { + return []sdk.CheckState{unknownState( + "tls.peer_certificate_present.skipped", + "No endpoint completed a TLS handshake.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.peer_certificate_present.ok", + "Every endpoint presented a certificate.", + )} + } + return out +} + +// chainValidityRule flags invalid certificate chains. +type chainValidityRule struct{} + +func (r *chainValidityRule) Name() string { return "tls.chain_validity" } +func (r *chainValidityRule) Description() string { + return "Verifies the presented certificate chain validates against the system trust store." +} + +func (r *chainValidityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.chain_validity.no_endpoints")} + } + + var out []sdk.CheckState + any := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.ChainValid == nil { + continue + } + any = true + if *p.ChainValid { + continue + } + msg := "Invalid certificate chain" + if p.ChainVerifyErr != "" { + msg = "Invalid certificate chain: " + p.ChainVerifyErr + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.chain_validity.invalid", + Subject: subjectOf(p), + Message: msg, + Meta: metaOf(p), + }) + } + if !any { + return []sdk.CheckState{unknownState( + "tls.chain_validity.skipped", + "No endpoint yielded a certificate chain to verify.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.chain_validity.ok", + "Every presented chain validates against the system trust store.", + )} + } + return out +} + +// hostnameMatchRule flags endpoints whose leaf cert does not cover the SNI +// the probe used. +type hostnameMatchRule struct{} + +func (r *hostnameMatchRule) Name() string { return "tls.hostname_match" } +func (r *hostnameMatchRule) Description() string { + return "Verifies the leaf certificate covers the probed hostname (SNI)." +} + +func (r *hostnameMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.hostname_match.no_endpoints")} + } + + var out []sdk.CheckState + any := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.HostnameMatch == nil { + continue + } + any = true + if *p.HostnameMatch { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.hostname_match.mismatch", + Subject: subjectOf(p), + Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", p.SNI, strings.Join(p.DNSNames, ", ")), + Meta: metaOf(p), + }) + } + if !any { + return []sdk.CheckState{unknownState( + "tls.hostname_match.skipped", + "No endpoint yielded a certificate to hostname-match.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.hostname_match.ok", + "Every certificate covers its probed SNI.", + )} + } + return out +} + +// expiryRule flags expired or near-expiry certificates. +type expiryRule struct{} + +func (r *expiryRule) Name() string { return "tls.expiry" } +func (r *expiryRule) Description() string { + return "Flags expired or soon-to-expire leaf certificates." +} + +func (r *expiryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.expiry.no_endpoints")} + } + + now := time.Now() + var out []sdk.CheckState + any := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.NotAfter.IsZero() { + continue + } + any = true + meta := metaOf(p) + meta["not_after"] = p.NotAfter + if p.NotAfter.Before(now) { + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.expiry.expired", + Subject: subjectOf(p), + Message: "Certificate expired on " + p.NotAfter.Format(time.RFC3339), + Meta: meta, + }) + continue + } + if p.NotAfter.Sub(now) < ExpiringSoonThreshold { + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.expiry.expiring_soon", + Subject: subjectOf(p), + Message: "Certificate expires in less than 14 days (" + p.NotAfter.Format(time.RFC3339) + ")", + Meta: meta, + }) + } + } + if !any { + return []sdk.CheckState{unknownState( + "tls.expiry.skipped", + "No endpoint yielded a certificate with an expiry to check.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.expiry.ok", + "Every leaf certificate is valid for more than 14 days.", + )} + } + return out +} diff --git a/checker/rules_discovery.go b/checker/rules_discovery.go new file mode 100644 index 0000000..738b235 --- /dev/null +++ b/checker/rules_discovery.go @@ -0,0 +1,34 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// endpointsDiscoveredRule surfaces the "no producer has published endpoints +// for this target yet" steady state. Kept as its own rule so it does not +// contaminate per-endpoint findings when discovery is in flight. +type endpointsDiscoveredRule struct{} + +func (r *endpointsDiscoveredRule) Name() string { return "tls.endpoints_discovered" } +func (r *endpointsDiscoveredRule) Description() string { + return "Verifies that at least one TLS endpoint has been discovered for this target." +} + +func (r *endpointsDiscoveredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{unknownState( + "tls.endpoints_discovered.none", + "No TLS endpoints have been discovered for this target yet.", + )} + } + return []sdk.CheckState{passState( + "tls.endpoints_discovered.ok", + "TLS endpoints were discovered for this target.", + )} +} diff --git a/checker/rules_handshake.go b/checker/rules_handshake.go new file mode 100644 index 0000000..2ee343c --- /dev/null +++ b/checker/rules_handshake.go @@ -0,0 +1,60 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// tlsHandshakeRule flags reachable endpoints on which the TLS handshake +// failed. STARTTLS-specific shortfalls (server not advertising the upgrade) +// are surfaced by starttlsAdvertisedRule / starttlsSupportedRule instead, +// so this rule skips them. +type tlsHandshakeRule struct{} + +func (r *tlsHandshakeRule) Name() string { return "tls.handshake" } +func (r *tlsHandshakeRule) Description() string { + return "Verifies the TLS handshake completes on every reachable endpoint." +} + +func (r *tlsHandshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.handshake.no_endpoints")} + } + + var out []sdk.CheckState + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.TCPError != "" { + continue // reachability covers this. + } + if p.STARTTLSNotOffered || p.STARTTLSUnsupportedProto { + continue // starttls-specific rules cover these. + } + if p.TLSHandshakeOK { + continue + } + if p.HandshakeError == "" { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.handshake.failed", + Subject: subjectOf(p), + Message: fmt.Sprintf("TLS handshake failed on %s: %s", p.Endpoint, p.HandshakeError), + Meta: metaOf(p), + }) + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.handshake.ok", + "TLS handshake succeeded on every reachable endpoint.", + )} + } + return out +} diff --git a/checker/rules_protocol.go b/checker/rules_protocol.go new file mode 100644 index 0000000..f9e24ad --- /dev/null +++ b/checker/rules_protocol.go @@ -0,0 +1,105 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// tlsVersionRule flags endpoints negotiating a protocol version below the +// recommended TLS 1.2 floor. +type tlsVersionRule struct{} + +func (r *tlsVersionRule) Name() string { return "tls.version" } +func (r *tlsVersionRule) Description() string { + return "Flags endpoints negotiating a TLS version below the recommended TLS 1.2." +} + +func (r *tlsVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.version.no_endpoints")} + } + + var out []sdk.CheckState + any := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.TLSVersionNum == 0 { + continue + } + any = true + if p.TLSVersionNum >= tls.VersionTLS12 { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.version.weak", + Subject: subjectOf(p), + Message: fmt.Sprintf("Negotiated TLS version %s is below the recommended TLS 1.2.", p.TLSVersion), + Meta: metaOf(p), + }) + } + if !any { + return []sdk.CheckState{unknownState( + "tls.version.skipped", + "No endpoint completed a TLS handshake.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.version.ok", + "Every endpoint negotiates TLS 1.2 or higher.", + )} + } + return out +} + +// cipherSuiteRule reports the negotiated cipher suite for visibility. +// It does not currently classify suites as weak/strong: go's crypto/tls +// refuses to negotiate the known-weak suites anyway. The rule exists so the +// UI can expose the suite in the passing-list rather than leaving it buried +// in the raw observation. +type cipherSuiteRule struct{} + +func (r *cipherSuiteRule) Name() string { return "tls.cipher_suite" } +func (r *cipherSuiteRule) Description() string { + return "Reports the cipher suite negotiated on each endpoint." +} + +func (r *cipherSuiteRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.cipher_suite.no_endpoints")} + } + + var out []sdk.CheckState + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.CipherSuite == "" { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusInfo, + Code: "tls.cipher_suite.negotiated", + Subject: subjectOf(p), + Message: fmt.Sprintf("Cipher suite %s negotiated.", p.CipherSuite), + Meta: metaOf(p), + }) + } + if len(out) == 0 { + return []sdk.CheckState{unknownState( + "tls.cipher_suite.skipped", + "No endpoint completed a TLS handshake.", + )} + } + return out +} diff --git a/checker/rules_reachability.go b/checker/rules_reachability.go new file mode 100644 index 0000000..6540059 --- /dev/null +++ b/checker/rules_reachability.go @@ -0,0 +1,48 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// reachabilityRule flags endpoints that did not accept a TCP connection. +type reachabilityRule struct{} + +func (r *reachabilityRule) Name() string { return "tls.reachability" } +func (r *reachabilityRule) Description() string { + return "Verifies that every discovered TLS endpoint accepts a TCP connection." +} + +func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.reachability.no_endpoints")} + } + + var out []sdk.CheckState + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.TCPError == "" { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "tls.reachability.tcp_unreachable", + Subject: subjectOf(p), + Message: fmt.Sprintf("Cannot open TCP connection to %s: %s", p.Endpoint, p.TCPError), + Meta: metaOf(p), + }) + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.reachability.ok", + "All discovered endpoints accepted a TCP connection.", + )} + } + return out +} diff --git a/checker/rules_starttls.go b/checker/rules_starttls.go new file mode 100644 index 0000000..cea5cdc --- /dev/null +++ b/checker/rules_starttls.go @@ -0,0 +1,108 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// starttlsAdvertisedRule flags STARTTLS endpoints whose server did not +// advertise the upgrade. Severity depends on RequireSTARTTLS: opportunistic +// STARTTLS degrades to a warning; mandatory STARTTLS is critical. +type starttlsAdvertisedRule struct{} + +func (r *starttlsAdvertisedRule) Name() string { return "tls.starttls_advertised" } +func (r *starttlsAdvertisedRule) Description() string { + return "Verifies that STARTTLS endpoints advertise the upgrade capability." +} + +func (r *starttlsAdvertisedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.starttls_advertised.no_endpoints")} + } + + var out []sdk.CheckState + anySTARTTLS := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.STARTTLSDialect == "" { + continue + } + anySTARTTLS = true + if !p.STARTTLSNotOffered { + continue + } + status := sdk.StatusWarn + if p.RequireSTARTTLS { + status = sdk.StatusCrit + } + out = append(out, sdk.CheckState{ + Status: status, + Code: "tls.starttls_advertised.missing", + Subject: subjectOf(p), + Message: fmt.Sprintf("Server on %s does not advertise STARTTLS.", p.Endpoint), + Meta: metaOf(p), + }) + } + if !anySTARTTLS { + return []sdk.CheckState{unknownState( + "tls.starttls_advertised.not_applicable", + "No STARTTLS endpoint in the discovered set.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.starttls_advertised.ok", + "STARTTLS is advertised on every STARTTLS endpoint.", + )} + } + return out +} + +// starttlsSupportedRule flags endpoints whose STARTTLS dialect is not +// implemented by this checker. A misconfigured discovery entry (typo, new +// protocol) should be visible as its own concern rather than blending into +// generic handshake failures. +type starttlsSupportedRule struct{} + +func (r *starttlsSupportedRule) Name() string { return "tls.starttls_dialect_supported" } +func (r *starttlsSupportedRule) Description() string { + return "Verifies that discovered STARTTLS dialects are implemented by the checker." +} + +func (r *starttlsSupportedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.starttls_dialect_supported.no_endpoints")} + } + + var out []sdk.CheckState + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if !p.STARTTLSUnsupportedProto { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusError, + Code: "tls.starttls_dialect_supported.unknown", + Subject: subjectOf(p), + Message: fmt.Sprintf("Unsupported STARTTLS dialect %q for %s.", p.STARTTLSDialect, p.Endpoint), + Meta: metaOf(p), + }) + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.starttls_dialect_supported.ok", + "Every STARTTLS dialect encountered is implemented.", + )} + } + return out +} diff --git a/checker/types.go b/checker/types.go index 3509d23..0dfd8b3 100644 --- a/checker/types.go +++ b/checker/types.go @@ -22,33 +22,67 @@ const ( MaxConcurrentProbes = 32 ) -// Severity values used in Issue.Severity (lowercase, ascii). -const ( - SeverityCrit = "crit" - SeverityWarn = "warn" - SeverityInfo = "info" -) - // TLSData is the full collected payload written under ObservationKeyTLSProbes. type TLSData struct { Probes map[string]TLSProbe `json:"probes"` CollectedAt time.Time `json:"collected_at"` } -// TLSProbe captures the outcome of probing a single endpoint. Field names -// mirror what consumers already parse (checker-xmpp's tlsProbeView). +// TLSProbe captures the outcome of probing a single endpoint. +// +// Only raw observation fields live here. Judgement (severity, pass/fail, +// human-facing messages) is derived from these fields by CheckRules. type TLSProbe struct { - Host string `json:"host"` - Port uint16 `json:"port"` - Endpoint string `json:"endpoint"` - Type string `json:"type"` - SNI string `json:"sni,omitempty"` - TLSVersion string `json:"tls_version,omitempty"` - CipherSuite string `json:"cipher_suite,omitempty"` - HostnameMatch *bool `json:"hostname_match,omitempty"` - ChainValid *bool `json:"chain_valid,omitempty"` - NotAfter time.Time `json:"not_after,omitempty"` - Issuer string `json:"issuer,omitempty"` + Host string `json:"host"` + Port uint16 `json:"port"` + Endpoint string `json:"endpoint"` + Type string `json:"type"` + SNI string `json:"sni,omitempty"` + + // RequireSTARTTLS is copied from the discovery entry so rules can tell + // whether a missing STARTTLS advertisement is a hard or soft failure. + RequireSTARTTLS bool `json:"require_starttls,omitempty"` + + // STARTTLSDialect mirrors contract.TLSEndpoint.STARTTLS verbatim. An + // empty value means direct TLS. + STARTTLSDialect string `json:"starttls_dialect,omitempty"` + + // Raw error strings. Exactly one of TCPError or HandshakeError is set + // when the probe failed before gathering handshake data. + TCPError string `json:"tcp_error,omitempty"` + HandshakeError string `json:"handshake_error,omitempty"` + + // STARTTLSNotOffered is true when HandshakeError was produced because + // the server did not advertise STARTTLS (errStartTLSNotOffered). + STARTTLSNotOffered bool `json:"starttls_not_offered,omitempty"` + + // STARTTLSUnsupportedProto is true when the STARTTLS dialect is not + // implemented by this checker. + STARTTLSUnsupportedProto bool `json:"starttls_unsupported_proto,omitempty"` + + // TLSHandshakeOK is true when a TLS handshake completed. It is + // independent from chain validity. + TLSHandshakeOK bool `json:"tls_handshake_ok,omitempty"` + + // TLSVersionNum is the numeric TLS version negotiated (uint16 from + // crypto/tls). Zero means no handshake occurred. Kept as an unsigned + // integer so rules can compare against tls.VersionTLS12 without + // re-parsing a string. + TLSVersionNum uint16 `json:"tls_version_num,omitempty"` + + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + CipherSuiteID uint16 `json:"cipher_suite_id,omitempty"` + + // NoPeerCert is true when the handshake succeeded but the server sent + // no certificate. + NoPeerCert bool `json:"no_peer_cert,omitempty"` + + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + ChainVerifyErr string `json:"chain_verify_err,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for // matching the CCADB CAA Identifiers CSV "Subject" column when the AKI // lookup misses. @@ -65,8 +99,11 @@ type TLSProbe struct { // DANE consumers can match without re-handshaking or re-parsing. Chain []CertInfo `json:"chain,omitempty"` ElapsedMS int64 `json:"elapsed_ms,omitempty"` - Error string `json:"error,omitempty"` - Issues []Issue `json:"issues,omitempty"` + + // Error is a compatibility summary of whichever raw error applies. + // Left for any external consumer still inspecting it; rules should + // look at TCPError / HandshakeError instead. + Error string `json:"error,omitempty"` } // CertInfo describes one certificate in the presented chain together with @@ -101,10 +138,7 @@ type CertInfo struct { SPKIDERBase64 string `json:"spki_der_base64,omitempty"` } -// Issue is a single TLS finding surfaced to the consumer. -type Issue struct { - Code string `json:"code"` - Severity string `json:"severity"` - Message string `json:"message,omitempty"` - Fix string `json:"fix,omitempty"` -} +// Expiry thresholds shared by rules. +const ( + ExpiringSoonThreshold = 14 * 24 * time.Hour +) diff --git a/contract/contract.go b/contract/contract.go index bc038b0..52f1be1 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -123,7 +123,7 @@ type Entry struct { } // ParseEntries filters entries to those of Type and decodes each payload. -// Entries of other types are ignored silently — they belong to other +// Entries of other types are ignored silently, they belong to other // contracts. Entries of this type whose Payload fails to unmarshal are // skipped and returned as warnings so a single malformed payload cannot // starve the checker of the rest of its workload. From e32633ca40c3c22c9a168bd632c842a8048232b8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Apr 2026 23:15:06 +0700 Subject: [PATCH 08/16] Harden STARTTLS handlers and add per-dialect tests 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/starttls.go | 37 +++++- checker/starttls_imap.go | 12 +- checker/starttls_pop3.go | 16 +-- checker/starttls_smtp.go | 2 +- checker/starttls_test.go | 274 +++++++++++++++++++++++++++++++++++++++ checker/starttls_xmpp.go | 8 +- 6 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 checker/starttls_test.go diff --git a/checker/starttls.go b/checker/starttls.go index 8fb1edd..892e533 100644 --- a/checker/starttls.go +++ b/checker/starttls.go @@ -1,6 +1,41 @@ package checker -import "net" +import ( + "bufio" + "fmt" + "io" + "net" +) + +// maxSTARTTLSLineBytes caps the length of a single line read from a STARTTLS +// peer. Real banners and CAPABILITY responses are well under 1 KiB; this +// bound prevents a malicious or buggy server from exhausting memory by +// withholding the line terminator. +const maxSTARTTLSLineBytes = 8 * 1024 + +// readLineLimited reads bytes from r up to and including the next '\n', or +// until maxSTARTTLSLineBytes have been read without one (in which case it +// returns an error). The returned string keeps the trailing '\n' so callers +// can use the same parsing logic as bufio.Reader.ReadString('\n'). +func readLineLimited(r *bufio.Reader) (string, error) { + out := make([]byte, 0, 128) + for { + b, err := r.ReadByte() + if err != nil { + if err == io.EOF && len(out) > 0 { + return string(out), io.ErrUnexpectedEOF + } + return string(out), err + } + out = append(out, b) + if b == '\n' { + return string(out), nil + } + if len(out) >= maxSTARTTLSLineBytes { + return string(out), fmt.Errorf("line exceeds %d bytes without terminator", maxSTARTTLSLineBytes) + } + } +} // starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on // conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success diff --git a/checker/starttls_imap.go b/checker/starttls_imap.go index 7a7a6fb..777e38d 100644 --- a/checker/starttls_imap.go +++ b/checker/starttls_imap.go @@ -15,7 +15,7 @@ func init() { func starttlsIMAP(conn net.Conn, sni string) error { rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) - if _, err := rw.ReadString('\n'); err != nil { + if _, err := readLineLimited(rw.Reader); err != nil { return fmt.Errorf("read greeting: %w", err) } @@ -23,12 +23,12 @@ func starttlsIMAP(conn net.Conn, sni string) error { return fmt.Errorf("write CAPABILITY: %w", err) } if err := rw.Flush(); err != nil { - return err + return fmt.Errorf("flush CAPABILITY: %w", err) } supportsSTARTTLS := false for { - line, err := rw.ReadString('\n') + line, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read CAPABILITY: %w", err) } @@ -44,13 +44,13 @@ func starttlsIMAP(conn net.Conn, sni string) error { } if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil { - return err + return fmt.Errorf("write STARTTLS: %w", err) } if err := rw.Flush(); err != nil { - return err + return fmt.Errorf("flush STARTTLS: %w", err) } for { - line, err := rw.ReadString('\n') + line, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read STARTTLS response: %w", err) } diff --git a/checker/starttls_pop3.go b/checker/starttls_pop3.go index 887933a..f46414c 100644 --- a/checker/starttls_pop3.go +++ b/checker/starttls_pop3.go @@ -15,7 +15,7 @@ func init() { func starttlsPOP3(conn net.Conn, sni string) error { rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) - greeting, err := rw.ReadString('\n') + greeting, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read greeting: %w", err) } @@ -24,19 +24,19 @@ func starttlsPOP3(conn net.Conn, sni string) error { } if _, err := rw.WriteString("CAPA\r\n"); err != nil { - return err + return fmt.Errorf("write CAPA: %w", err) } if err := rw.Flush(); err != nil { - return err + return fmt.Errorf("flush CAPA: %w", err) } - first, err := rw.ReadString('\n') + first, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read CAPA: %w", err) } supportsSTLS := false if strings.HasPrefix(first, "+OK") { for { - line, err := rw.ReadString('\n') + line, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read CAPA body: %w", err) } @@ -54,12 +54,12 @@ func starttlsPOP3(conn net.Conn, sni string) error { } if _, err := rw.WriteString("STLS\r\n"); err != nil { - return err + return fmt.Errorf("write STLS: %w", err) } if err := rw.Flush(); err != nil { - return err + return fmt.Errorf("flush STLS: %w", err) } - resp, err := rw.ReadString('\n') + resp, err := readLineLimited(rw.Reader) if err != nil { return fmt.Errorf("read STLS response: %w", err) } diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go index 39db327..dfbaa19 100644 --- a/checker/starttls_smtp.go +++ b/checker/starttls_smtp.go @@ -60,7 +60,7 @@ func readSMTPGreeting(r *bufio.Reader) error { func readSMTPResponse(r *bufio.Reader) ([]string, error) { var out []string for { - line, err := r.ReadString('\n') + line, err := readLineLimited(r) if err != nil { return out, err } diff --git a/checker/starttls_test.go b/checker/starttls_test.go new file mode 100644 index 0000000..9d05e8f --- /dev/null +++ b/checker/starttls_test.go @@ -0,0 +1,274 @@ +package checker + +import ( + "bufio" + "errors" + "io" + "net" + "strings" + "testing" + "time" +) + +// runStartTLS drives upgrader against a fake server. The server callback runs +// on the peer end of an in-memory pipe and may read/write the plaintext +// dialect transcript. The test deadline guards both ends from hanging. +func runStartTLS(t *testing.T, upgrader func(net.Conn, string) error, sni string, server func(net.Conn) error) error { + t.Helper() + clientConn, serverConn := net.Pipe() + deadline := time.Now().Add(2 * time.Second) + _ = clientConn.SetDeadline(deadline) + _ = serverConn.SetDeadline(deadline) + + srvErr := make(chan error, 1) + go func() { + defer serverConn.Close() + srvErr <- server(serverConn) + }() + + clientErr := upgrader(clientConn, sni) + clientConn.Close() + + if err := <-srvErr; err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { + t.Logf("server side returned: %v", err) + } + return clientErr +} + +// readLineCRLF reads one CRLF-terminated line. +func readLineCRLF(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + return strings.TrimRight(line, "\r\n"), err +} + +func TestStartTLS_SMTP_OK(t *testing.T) { + err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + if _, err := io.WriteString(c, "220 mail.example.com ESMTP\r\n"); err != nil { + return err + } + ehlo, err := readLineCRLF(br) + if err != nil { + return err + } + if !strings.HasPrefix(ehlo, "EHLO ") { + return errors.New("expected EHLO") + } + if _, err := io.WriteString(c, "250-mail.example.com\r\n250-SIZE 10485760\r\n250 STARTTLS\r\n"); err != nil { + return err + } + stls, err := readLineCRLF(br) + if err != nil { + return err + } + if stls != "STARTTLS" { + return errors.New("expected STARTTLS") + } + _, err = io.WriteString(c, "220 ready\r\n") + return err + }) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestStartTLS_SMTP_NotAdvertised(t *testing.T) { + err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "220 mail.example.com ESMTP\r\n") + if _, err := readLineCRLF(br); err != nil { + return err + } + _, err := io.WriteString(c, "250-mail.example.com\r\n250 SIZE 10485760\r\n") + return err + }) + if !errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("expected errStartTLSNotOffered, got: %v", err) + } +} + +func TestStartTLS_SMTP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsSMTP, "mail.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "220 mail.example.com ESMTP\r\n") + _, _ = readLineCRLF(br) + _, _ = io.WriteString(c, "250-mail.example.com\r\n250 STARTTLS\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "454 TLS not available\r\n") + return err + }) + if err == nil { + t.Fatal("expected refusal error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("refusal should not be classified as not-offered: %v", err) + } +} + +func TestStartTLS_IMAP_OK(t *testing.T) { + err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n") + cap1, err := readLineCRLF(br) + if err != nil { + return err + } + if !strings.HasSuffix(cap1, "CAPABILITY") { + return errors.New("expected CAPABILITY") + } + _, _ = io.WriteString(c, "* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\nA001 OK CAPABILITY completed\r\n") + stls, err := readLineCRLF(br) + if err != nil { + return err + } + if !strings.HasSuffix(stls, "STARTTLS") { + return errors.New("expected STARTTLS") + } + _, err = io.WriteString(c, "A002 OK Begin TLS\r\n") + return err + }) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestStartTLS_IMAP_NotAdvertised(t *testing.T) { + err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "* CAPABILITY IMAP4rev1 LOGINDISABLED\r\nA001 OK CAPABILITY completed\r\n") + return err + }) + if !errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("expected errStartTLSNotOffered, got: %v", err) + } +} + +func TestStartTLS_POP3_OK(t *testing.T) { + err := runStartTLS(t, starttlsPOP3, "pop.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "+OK POP3 ready\r\n") + capa, err := readLineCRLF(br) + if err != nil { + return err + } + if capa != "CAPA" { + return errors.New("expected CAPA") + } + _, _ = io.WriteString(c, "+OK capa list\r\nUSER\r\nSTLS\r\n.\r\n") + stls, err := readLineCRLF(br) + if err != nil { + return err + } + if stls != "STLS" { + return errors.New("expected STLS") + } + _, err = io.WriteString(c, "+OK begin TLS\r\n") + return err + }) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestStartTLS_POP3_NotAdvertised(t *testing.T) { + err := runStartTLS(t, starttlsPOP3, "pop.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "+OK POP3 ready\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "+OK capa list\r\nUSER\r\n.\r\n") + return err + }) + if !errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("expected errStartTLSNotOffered, got: %v", err) + } +} + +func TestStartTLS_XMPP_OK(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + // Read the client's stream header (one line is enough for our writer). + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, _ = io.WriteString(c, + ``+ + ``) + // Read the request from the client. + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, ``) + return err + }) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestStartTLS_XMPP_NotAdvertised(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, + ``+ + `PLAIN`) + return err + }) + if !errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("expected errStartTLSNotOffered, got: %v", err) + } +} + +func TestStartTLS_LDAP_OK(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + // Drain the StartTLS request (fixed 31 bytes: 0x30 0x1d + 29 bytes). + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // Build a minimal ExtendedResponse with resultCode=0. + // LDAPMessage SEQUENCE { messageID INTEGER 1, [APPLICATION 24] SEQUENCE { resultCode ENUMERATED 0, matchedDN "", diagnosticMessage "" } } + resp := []byte{ + 0x30, 0x0c, // SEQUENCE, length 12 + 0x02, 0x01, 0x01, // messageID = 1 + 0x78, 0x07, // [APPLICATION 24], length 7 + 0x0a, 0x01, 0x00, // resultCode ENUMERATED 0 + 0x04, 0x00, // matchedDN "" + 0x04, 0x00, // diagnosticMessage "" + } + _, err := c.Write(resp) + return err + }) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestStartTLS_LDAP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // resultCode = 53 (unwillingToPerform) -> classified as not-offered. + resp := []byte{ + 0x30, 0x0c, + 0x02, 0x01, 0x01, + 0x78, 0x07, + 0x0a, 0x01, 0x35, + 0x04, 0x00, + 0x04, 0x00, + } + _, err := c.Write(resp) + return err + }) + if !errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("expected errStartTLSNotOffered for resultCode 53, got: %v", err) + } +} diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go index d810654..dfed8f2 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -32,6 +32,7 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error { // Read the inbound opening and its . hasStartTLS := false +outer: for { tok, err := dec.Token() if err != nil { @@ -50,17 +51,18 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error { if ee.Name.Local == "starttls" { hasStartTLS = true } - _ = dec.Skip() + if err := dec.Skip(); err != nil { + return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err) + } case xml.EndElement: if ee.Name.Local == "features" { - goto doneFeatures + break outer } } } } } } -doneFeatures: if !hasStartTLS { return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered) } From c99c13a7e037ca1bfe2edcd6b04cbd3519a70ee8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 25 Apr 2026 23:38:46 +0700 Subject: [PATCH 09/16] fix: Implement CheckerDefinitionProvider on tlsProvider Lets the SDK seed OptionEndpoints from the primary's DiscoverEntries output when checker-tls runs as a sibling. --- checker/definition.go | 2 +- plugin/plugin.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/checker/definition.go b/checker/definition.go index c60fdd0..b948e5b 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -11,7 +11,7 @@ import ( var Version = "built-in" // Definition returns the CheckerDefinition for the TLS checker. -func Definition() *sdk.CheckerDefinition { +func (p *tlsProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "tls", Name: "TLS", diff --git a/plugin/plugin.go b/plugin/plugin.go index 6648452..991152a 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -9,5 +9,6 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { tls.Version = Version - return tls.Definition(), tls.Provider(), nil + prvd := tls.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil } From ad32e1c757f109383454da3798b89c5dc9eb3b5f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 01:10:32 +0700 Subject: [PATCH 10/16] Run container as non-root user Add USER 65534:65534 to the scratch runtime image so the checker process does not run as root. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 716c7d1..ab52673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,6 @@ RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_ FROM scratch COPY --from=builder /checker-tls /checker-tls +USER 65534:65534 EXPOSE 8080 ENTRYPOINT ["/checker-tls"] From a925e4f162a0f56a3e488295aa47035034a165b2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 10:53:12 +0700 Subject: [PATCH 11/16] docker: add HEALTHCHECK probing /health The binary doubles as its own healthcheck client via the SDK's -healthcheck flag, so the probe works in the scratch image (no shell, no curl, no wget). --- Dockerfile | 2 ++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab52673..c4abb45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,6 @@ FROM scratch COPY --from=builder /checker-tls /checker-tls USER 65534:65534 EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/checker-tls", "-healthcheck"] ENTRYPOINT ["/checker-tls"] diff --git a/go.mod b/go.mod index bde2901..9656a31 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module git.happydns.org/checker-tls go 1.25.0 -require git.happydns.org/checker-sdk-go v1.4.0 +require git.happydns.org/checker-sdk-go v1.5.0 diff --git a/go.sum b/go.sum index 072aab1..c389c68 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= -git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= From fa212f0faeb32f6a7cfd207a3d08a5ea72087ed4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 16:39:22 +0700 Subject: [PATCH 12/16] Harden contract validation, STARTTLS edge cases, and rule output --- checker/fetch.go | 2 +- checker/prober.go | 4 ++-- checker/rules_protocol.go | 35 +++++++++++++++++++-------- checker/starttls_imap.go | 4 ++++ checker/starttls_smtp.go | 7 +++++- checker/starttls_xmpp.go | 50 ++++++++++++++++++++++++--------------- checker/types.go | 10 ++++---- contract/contract.go | 23 +++++++++++++++++- main.go | 8 +++++++ 9 files changed, 104 insertions(+), 39 deletions(-) diff --git a/checker/fetch.go b/checker/fetch.go index 1cdd816..1a078d1 100644 --- a/checker/fetch.go +++ b/checker/fetch.go @@ -68,7 +68,7 @@ func FetchChain(ctx context.Context, host string, port uint16, starttls string, tlsConn := tls.Client(conn, &tls.Config{ ServerName: host, - InsecureSkipVerify: true, + InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it }) if err := tlsConn.HandshakeContext(dialCtx); err != nil { return nil, fmt.Errorf("tls handshake: %w", err) diff --git a/checker/prober.go b/checker/prober.go index 74a05ef..1aa25ee 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -172,7 +172,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { cfg := &tls.Config{ ServerName: sni, - InsecureSkipVerify: true, + InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe() } if ep.STARTTLS == "" { @@ -198,7 +198,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e } var ( - errStartTLSNotOffered = errors.New("starttls not advertised by server") + errStartTLSNotOffered = errors.New("starttls not advertised by server") errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol") ) diff --git a/checker/rules_protocol.go b/checker/rules_protocol.go index f9e24ad..6153b38 100644 --- a/checker/rules_protocol.go +++ b/checker/rules_protocol.go @@ -4,6 +4,8 @@ import ( "context" "crypto/tls" "fmt" + "sort" + "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -81,25 +83,38 @@ func (r *cipherSuiteRule) Evaluate(ctx context.Context, obs sdk.ObservationGette return []sdk.CheckState{emptyCaseState("tls.cipher_suite.no_endpoints")} } - var out []sdk.CheckState + // Collapse per-endpoint cipher suites into a single info state. One + // row per endpoint drowns out actionable rules in the UI on domains + // with many endpoints; an aggregated list is enough for visibility. + suites := map[string]int{} + endpoints := map[string][]string{} for _, ref := range sortedRefs(data) { p := data.Probes[ref] if p.CipherSuite == "" { continue } - out = append(out, sdk.CheckState{ - Status: sdk.StatusInfo, - Code: "tls.cipher_suite.negotiated", - Subject: subjectOf(p), - Message: fmt.Sprintf("Cipher suite %s negotiated.", p.CipherSuite), - Meta: metaOf(p), - }) + suites[p.CipherSuite]++ + endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint) } - if len(out) == 0 { + if len(suites) == 0 { return []sdk.CheckState{unknownState( "tls.cipher_suite.skipped", "No endpoint completed a TLS handshake.", )} } - return out + names := make([]string, 0, len(suites)) + for s := range suites { + names = append(names, s) + } + sort.Strings(names) + parts := make([]string, 0, len(names)) + for _, n := range names { + parts = append(parts, fmt.Sprintf("%s (%d)", n, suites[n])) + } + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "tls.cipher_suite.negotiated", + Message: "Negotiated cipher suites: " + strings.Join(parts, ", "), + Meta: map[string]any{"suites": endpoints}, + }} } diff --git a/checker/starttls_imap.go b/checker/starttls_imap.go index 777e38d..4c04010 100644 --- a/checker/starttls_imap.go +++ b/checker/starttls_imap.go @@ -36,6 +36,10 @@ func starttlsIMAP(conn net.Conn, sni string) error { supportsSTARTTLS = true } if strings.HasPrefix(line, "A001 ") { + rest := strings.TrimSpace(line[len("A001 "):]) + if !strings.HasPrefix(strings.ToUpper(rest), "OK") { + return fmt.Errorf("CAPABILITY rejected by server: %s", rest) + } break } } diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go index dfbaa19..ccc0211 100644 --- a/checker/starttls_smtp.go +++ b/checker/starttls_smtp.go @@ -7,6 +7,11 @@ import ( "strings" ) +// EHLOHostname is the hostname sent in the SMTP EHLO command during STARTTLS +// negotiation. Override it at startup (e.g. via -ldflags or programmatically) +// to match the identity of the host running the checker. +var EHLOHostname = "checker.localhost" + func init() { registerStartTLS("smtp", starttlsSMTP) registerStartTLS("submission", starttlsSMTP) @@ -20,7 +25,7 @@ func starttlsSMTP(conn net.Conn, sni string) error { return fmt.Errorf("read greeting: %w", err) } - if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil { + if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil { return fmt.Errorf("write ehlo: %w", err) } if err := rw.Flush(); err != nil { diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go index dfed8f2..22b421a 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -31,6 +31,9 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error { dec := xml.NewDecoder(conn) // Read the inbound opening and its . + // A peer that opens with (or anything other than features) + // is not going to advertise STARTTLS: surface that immediately rather + // than spinning on tokens until the deadline fires. hasStartTLS := false outer: for { @@ -38,29 +41,38 @@ outer: if err != nil { return fmt.Errorf("read stream features: %w", err) } - if se, ok := tok.(xml.StartElement); ok { - if se.Name.Local == "features" { - // Scan features children. - for { - t2, err := dec.Token() - if err != nil { - return fmt.Errorf("read features body: %w", err) + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + switch se.Name.Local { + case "stream": + // Outer opening. Continue reading children. + continue + case "features": + for { + t2, err := dec.Token() + if err != nil { + return fmt.Errorf("read features body: %w", err) + } + switch ee := t2.(type) { + case xml.StartElement: + if ee.Name.Local == "starttls" { + hasStartTLS = true } - switch ee := t2.(type) { - case xml.StartElement: - if ee.Name.Local == "starttls" { - hasStartTLS = true - } - if err := dec.Skip(); err != nil { - return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err) - } - case xml.EndElement: - if ee.Name.Local == "features" { - break outer - } + if err := dec.Skip(); err != nil { + return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err) + } + case xml.EndElement: + if ee.Name.Local == "features" { + break outer } } } + case "error": + return fmt.Errorf("server returned before features") + default: + return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local) } } if !hasStartTLS { diff --git a/checker/types.go b/checker/types.go index 0dfd8b3..8cbf23d 100644 --- a/checker/types.go +++ b/checker/types.go @@ -78,11 +78,11 @@ type TLSProbe struct { // no certificate. NoPeerCert bool `json:"no_peer_cert,omitempty"` - HostnameMatch *bool `json:"hostname_match,omitempty"` - ChainValid *bool `json:"chain_valid,omitempty"` - ChainVerifyErr string `json:"chain_verify_err,omitempty"` - NotAfter time.Time `json:"not_after,omitempty"` - Issuer string `json:"issuer,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + ChainVerifyErr string `json:"chain_verify_err,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for // matching the CCADB CAA Identifiers CSV "Subject" column when the AKI // lookup misses. diff --git a/contract/contract.go b/contract/contract.go index 52f1be1..76f7e07 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -16,6 +16,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -58,10 +59,27 @@ type TLSEndpoint struct { RequireSTARTTLS bool `json:"require,omitempty"` } +// Validate rejects endpoints that cannot be probed: empty Host or zero Port. +// STARTTLS dialect is intentionally not checked here (the checker surfaces +// unsupported dialects at runtime via the tls.starttls_dialect_supported +// rule), and SNI defaults to Host downstream. +func (ep TLSEndpoint) Validate() error { + if strings.TrimSpace(strings.TrimSuffix(ep.Host, ".")) == "" { + return fmt.Errorf("contract: TLSEndpoint.Host is required") + } + if ep.Port == 0 { + return fmt.Errorf("contract: TLSEndpoint.Port must be 1-65535") + } + return nil +} + // NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref // derived from ep, and a marshaled Payload. The returned entry can be // returned as-is from a DiscoveryPublisher implementation. func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { + if err := ep.Validate(); err != nil { + return sdk.DiscoveryEntry{}, err + } payload, err := json.Marshal(ep) if err != nil { return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) @@ -95,7 +113,7 @@ func Ref(ep TLSEndpoint) string { req = "1" } canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) - sum := sha1.Sum([]byte(canonical)) + sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above return hex.EncodeToString(sum[:8]) } @@ -109,6 +127,9 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) { if err := json.Unmarshal(e.Payload, &ep); err != nil { return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) } + if err := ep.Validate(); err != nil { + return TLSEndpoint{}, err + } return ep, nil } diff --git a/main.go b/main.go index ae5167c..c967401 100644 --- a/main.go +++ b/main.go @@ -10,11 +10,19 @@ import ( var Version = "custom-build" +// EHLOHostname is set via -ldflags to identify this checker instance in SMTP +// EHLO greetings. Falls back to the package default ("checker.localhost") when +// left empty. +var EHLOHostname = "" + var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() tls.Version = Version + if EHLOHostname != "" { + tls.EHLOHostname = EHLOHostname + } srv := server.New(tls.Provider()) if err := srv.ListenAndServe(*listenAddr); err != nil { From 8a7f9feaf7df1e935ef174e958a1260eccf18512 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 13:33:27 +0700 Subject: [PATCH 13/16] Document usage of FetchChain, BuildChain, AutoSTARTTLS, and CertInfo --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 61d4e1b..2d7e158 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,38 @@ existing downstream parsers. | ---------------- | ------ | ------- | -------------------------------------------- | | `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 From a9f37c79cf5eb9b3b71fed9e9d0a8bdac9cf8cc3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 13:34:27 +0700 Subject: [PATCH 14/16] Add tlsenum package and add version/cipher enumeration into the checker tlsenum package probes a remote endpoint with one ClientHello per (version, cipher) pair via utls, so the checker can report the exact set the server accepts rather than only the suite Go's stdlib happens to negotiate. Probe accepts an Upgrader callback so STARTTLS dialects plug in without tlsenum learning about them; the checker bridges its existing dialect registry through upgraderFor. --- checker/collect.go | 24 ++- checker/definition.go | 7 + checker/enumerate.go | 68 +++++++ checker/enumerate_test.go | 198 +++++++++++++++++++++ checker/rule.go | 2 + checker/rules_enumeration.go | 197 +++++++++++++++++++++ checker/rules_enumeration_test.go | 135 ++++++++++++++ checker/starttls.go | 15 ++ checker/starttls_ldap.go | 5 +- checker/starttls_test.go | 157 +++++++++++++++++ checker/starttls_xmpp.go | 9 +- checker/types.go | 39 +++- checker/upgrader_for_test.go | 91 ++++++++++ go.mod | 8 + go.sum | 10 ++ tlsenum/ciphers.go | 103 +++++++++++ tlsenum/tlsenum.go | 283 ++++++++++++++++++++++++++++++ tlsenum/tlsenum_test.go | 223 +++++++++++++++++++++++ 18 files changed, 1569 insertions(+), 5 deletions(-) create mode 100644 checker/enumerate.go create mode 100644 checker/enumerate_test.go create mode 100644 checker/rules_enumeration.go create mode 100644 checker/rules_enumeration_test.go create mode 100644 checker/upgrader_for_test.go create mode 100644 tlsenum/ciphers.go create mode 100644 tlsenum/tlsenum.go create mode 100644 tlsenum/tlsenum_test.go diff --git a/checker/collect.go b/checker/collect.go index 8db0ddb..d576fb3 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -22,6 +22,7 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any timeoutMs = DefaultProbeTimeoutMs } timeout := time.Duration(timeoutMs) * time.Millisecond + enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false) entries, warnings := contract.ParseEntries(raw) for _, w := range warnings { @@ -40,15 +41,36 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any var mu sync.Mutex var wg sync.WaitGroup sem := make(chan struct{}, MaxConcurrentProbes) +dispatch: for _, e := range entries { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + break dispatch + } wg.Add(1) - sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() pr := probe(ctx, e.Endpoint, timeout) log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q", pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error) + if enumerate && pr.TLSHandshakeOK { + enumRes, skipReason := enumerateEndpoint(ctx, e.Endpoint, enumerationBudget) + switch { + case enumRes != nil && enumRes.Skipped != "": + pr.Enum = enumRes + log.Printf("checker-tls: enum %s:%d → error: %s (duration=%dms)", + pr.Host, pr.Port, enumRes.Skipped, enumRes.DurationMS) + case enumRes != nil: + pr.Enum = enumRes + log.Printf("checker-tls: enum %s:%d → versions=%d duration=%dms", + pr.Host, pr.Port, len(enumRes.Versions), enumRes.DurationMS) + case skipReason != "": + log.Printf("checker-tls: enum %s:%d → skipped: %s", + pr.Host, pr.Port, skipReason) + } + } mu.Lock() probes[e.Ref] = pr mu.Unlock() diff --git a/checker/definition.go b/checker/definition.go index b948e5b..4fd166a 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -29,6 +29,13 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition { Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.", Default: float64(DefaultProbeTimeoutMs), }, + { + Id: OptionEnumerateCiphers, + Type: "boolean", + Label: "Enumerate accepted TLS versions and cipher suites", + Description: "When enabled, each direct-TLS endpoint is swept with one ClientHello per (version, cipher) pair to discover the exact set the server accepts. Adds ~50 handshakes per endpoint.", + Default: false, + }, }, RunOpts: []sdk.CheckerOptionDocumentation{ { diff --git a/checker/enumerate.go b/checker/enumerate.go new file mode 100644 index 0000000..61e76ef --- /dev/null +++ b/checker/enumerate.go @@ -0,0 +1,68 @@ +package checker + +import ( + "context" + "net" + "strconv" + "strings" + "time" + + "git.happydns.org/checker-tls/contract" + "git.happydns.org/checker-tls/tlsenum" +) + +// enumerationProbeTimeout caps each individual sub-probe. It is intentionally +// shorter than the main probe timeout: a sweep does dozens of handshakes and +// most rejections come back in tens of ms, so 3s is enough to absorb a slow +// network without dragging the total cost. +const enumerationProbeTimeout = 3 * time.Second + +// enumerateEndpoint runs a (version × cipher) sweep against an endpoint — +// direct TLS or STARTTLS — and returns the result in the wire-format consumed +// by rules. It returns (nil, "") to signal the sweep was deliberately +// skipped. +func enumerateEndpoint(ctx context.Context, ep contract.TLSEndpoint, totalBudget time.Duration) (*TLSEnumeration, string) { + host := strings.TrimSuffix(ep.Host, ".") + addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port))) + sni := ep.SNI + if sni == "" { + sni = host + } + + upgrader, ok := upgraderFor(ep.STARTTLS, sni) + if !ok { + return nil, "unsupported starttls dialect: " + ep.STARTTLS + } + + sweepCtx := ctx + if totalBudget > 0 { + var cancel context.CancelFunc + sweepCtx, cancel = context.WithTimeout(ctx, totalBudget) + defer cancel() + } + + start := time.Now() + res, err := tlsenum.Enumerate(sweepCtx, addr, sni, tlsenum.EnumerateOptions{ + ProbeTimeout: enumerationProbeTimeout, + Upgrader: upgrader, + }) + elapsed := time.Since(start).Milliseconds() + if err != nil { + return &TLSEnumeration{Skipped: "enumeration error: " + err.Error(), DurationMS: elapsed}, "" + } + + out := &TLSEnumeration{DurationMS: elapsed} + for _, v := range res.SupportedVersions { + ev := EnumVersion{Version: v, Name: tlsenum.VersionName(v)} + for _, c := range res.CiphersByVersion[v] { + ev.Ciphers = append(ev.Ciphers, EnumCipher{ID: c.ID, Name: c.Name}) + } + out.Versions = append(out.Versions, ev) + } + return out, "" +} + +// enumerationBudget is the upper bound we give one endpoint's sweep. ~50 +// handshakes × enumerationProbeTimeout would be 2-3 minutes worst case; we +// cap at 60s so a black-holing target can't stall the whole collect run. +const enumerationBudget = 60 * time.Second diff --git a/checker/enumerate_test.go b/checker/enumerate_test.go new file mode 100644 index 0000000..02484bc --- /dev/null +++ b/checker/enumerate_test.go @@ -0,0 +1,198 @@ +package checker + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "strconv" + "testing" + "time" + + "git.happydns.org/checker-tls/contract" +) + +// startEnumTestServer spins up a TCP listener that, for every accepted +// connection: (1) optionally drives a fake STARTTLS dialect handshake, then +// (2) lets the standard library terminate TLS with the provided cert. It +// keeps accepting until the test closes the listener. +// +// We use the stdlib tls.Server (not utls) on the server side: the point of +// these tests is to exercise the *checker* glue (upgraderFor + enumerate) +// against the real client-side code, not to replay tlsenum's internals. +func startEnumTestServer(t *testing.T, withSTARTTLS bool, cert tls.Certificate) net.Listener { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go handleEnumConn(c, withSTARTTLS, cert) + } + }() + return ln +} + +func handleEnumConn(c net.Conn, withSTARTTLS bool, cert tls.Certificate) { + defer c.Close() + if withSTARTTLS { + // Pretend to be SMTP: 220 banner, EHLO ack, STARTTLS ack. The + // implementation of starttlsSMTP only requires the server to + // advertise STARTTLS in its EHLO response and to reply with a 2xx + // to the STARTTLS verb — exact verbs come from RFC 3207. + if _, err := io.WriteString(c, "220 enum.test ESMTP\r\n"); err != nil { + return + } + buf := make([]byte, 1024) + // EHLO line + if _, err := c.Read(buf); err != nil { + return + } + if _, err := io.WriteString(c, "250-enum.test\r\n250 STARTTLS\r\n"); err != nil { + return + } + // STARTTLS line + if _, err := c.Read(buf); err != nil { + return + } + if _, err := io.WriteString(c, "220 ready\r\n"); err != nil { + return + } + } + tc := tls.Server(c, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, // narrow surface so the sweep is fast + }) + defer tc.Close() + _ = tc.Handshake() +} + +// enumTestCert is a one-time self-signed ECDSA cert reused across tests. +func enumTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "enum.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"enum.test"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("createcert: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + c, err := tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + ) + if err != nil { + t.Fatalf("keypair: %v", err) + } + return c +} + +func portOf(t *testing.T, ln net.Listener) uint16 { + t.Helper() + _, p, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("split addr: %v", err) + } + n, err := strconv.ParseUint(p, 10, 16) + if err != nil { + t.Fatalf("parse port: %v", err) + } + return uint16(n) +} + +// TestEnumerateEndpoint_DirectTLS asserts the sweep returns at least one +// supported version + cipher when the endpoint is plain TLS — proving the +// nil-upgrader path of upgraderFor wires correctly. +func TestEnumerateEndpoint_DirectTLS(t *testing.T) { + cert := enumTestCert(t) + ln := startEnumTestServer(t, false, cert) + defer ln.Close() + + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: portOf(t, ln), + SNI: "enum.test", + }, 30*time.Second) + if skip != "" { + t.Fatalf("unexpected skip reason: %q", skip) + } + if res == nil || len(res.Versions) == 0 { + t.Fatalf("expected at least one supported version, got %+v", res) + } + gotTLS12 := false + for _, v := range res.Versions { + if v.Version == tls.VersionTLS12 && len(v.Ciphers) > 0 { + gotTLS12 = true + } + } + if !gotTLS12 { + t.Fatalf("expected TLS 1.2 with at least one cipher, got %+v", res.Versions) + } +} + +// TestEnumerateEndpoint_SMTP_STARTTLS asserts the sweep drives the SMTP +// dialect upgrade on every sub-probe and still discovers ciphers — proving +// the upgraderFor("smtp", sni) path is wired into Enumerate. +func TestEnumerateEndpoint_SMTP_STARTTLS(t *testing.T) { + cert := enumTestCert(t) + ln := startEnumTestServer(t, true, cert) + defer ln.Close() + + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: portOf(t, ln), + SNI: "enum.test", + STARTTLS: "smtp", + }, 60*time.Second) + if skip != "" { + t.Fatalf("unexpected skip reason: %q", skip) + } + if res == nil || len(res.Versions) == 0 { + t.Fatalf("expected at least one supported version through STARTTLS, got %+v", res) + } +} + +// TestEnumerateEndpoint_UnknownDialect asserts an unsupported STARTTLS +// dialect is rejected with a non-empty skip reason and no result — the +// observation must record *why* enumeration didn't run, not silently report +// "no versions accepted". +func TestEnumerateEndpoint_UnknownDialect(t *testing.T) { + res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{ + Host: "127.0.0.1", + Port: 1, // unreachable on purpose; we never get past the dialect check + STARTTLS: "no-such-dialect", + }, time.Second) + if res != nil { + t.Fatalf("expected nil result for unknown dialect, got %+v", res) + } + if skip == "" { + t.Fatalf("expected non-empty skip reason for unknown dialect") + } +} diff --git a/checker/rule.go b/checker/rule.go index bcb1858..ad9d626 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -25,6 +25,8 @@ func Rules() []sdk.CheckRule { &expiryRule{}, &tlsVersionRule{}, &cipherSuiteRule{}, + &versionEnumerationRule{}, + &weakCipherRule{}, } } diff --git a/checker/rules_enumeration.go b/checker/rules_enumeration.go new file mode 100644 index 0000000..6757be0 --- /dev/null +++ b/checker/rules_enumeration.go @@ -0,0 +1,197 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + "sort" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// hasEnum returns true when at least one probe carries enumeration data. +// Rules use this to short-circuit to "skipped" when the user hasn't enabled +// the enumerate option (rather than falsely emitting a "passing" verdict). +func hasEnum(data *TLSData) bool { + for _, p := range data.Probes { + if p.Enum != nil && len(p.Enum.Versions) > 0 { + return true + } + } + return false +} + +// versionEnumerationRule reports the full set of protocol versions accepted +// by each endpoint, and flags any acceptance below the TLS 1.2 floor — the +// regular handshake rule only sees the *negotiated* version, so a server +// that still accepts TLS 1.0 alongside TLS 1.3 would otherwise look healthy. +type versionEnumerationRule struct{} + +func (r *versionEnumerationRule) Name() string { return "tls.enum.versions" } +func (r *versionEnumerationRule) Description() string { + return "Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option)." +} + +func (r *versionEnumerationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.enum.versions.no_endpoints")} + } + if !hasEnum(data) { + return []sdk.CheckState{unknownState( + "tls.enum.versions.skipped", + "TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).", + )} + } + + var out []sdk.CheckState + anyEnum := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.Enum == nil || len(p.Enum.Versions) == 0 { + continue + } + anyEnum = true + + var legacy []string + for _, v := range p.Enum.Versions { + if v.Version < tls.VersionTLS12 { + legacy = append(legacy, v.Name) + } + } + if len(legacy) == 0 { + continue + } + sort.Strings(legacy) + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.enum.versions.legacy_accepted", + Subject: subjectOf(p), + Message: fmt.Sprintf("Endpoint accepts legacy protocol version(s): %s.", strings.Join(legacy, ", ")), + Meta: metaOf(p), + }) + } + if !anyEnum { + return []sdk.CheckState{unknownState( + "tls.enum.versions.skipped", + "No endpoint produced enumeration data.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.enum.versions.ok", + "No endpoint accepts a protocol version below TLS 1.2.", + )} + } + return out +} + +// weakCipherRule flags endpoints that accept cipher suites widely considered +// broken or insecure: NULL, anonymous, EXPORT, RC4, 3DES, and any other CBC +// suite using SHA-1 in MAC-then-encrypt mode is *not* flagged here because +// real-world servers still need them for legacy clients; this rule limits +// itself to the set with no defensible use in 2026. +type weakCipherRule struct{} + +func (r *weakCipherRule) Name() string { return "tls.enum.ciphers" } +func (r *weakCipherRule) Description() string { + return "Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES)." +} + +// classifyCipher returns a non-empty category when the named cipher belongs +// to a class with no defensible modern use. The check is by substring on the +// IANA name because every entry follows the TLS__WITH__ +// convention. +func classifyCipher(name string) string { + upper := strings.ToUpper(name) + switch { + case strings.Contains(upper, "_NULL_"), strings.HasSuffix(upper, "_NULL"): + return "NULL" + case strings.Contains(upper, "_ANON_"): + return "anonymous" + case strings.Contains(upper, "_EXPORT_"): + return "EXPORT" + case strings.Contains(upper, "_RC4_"): + return "RC4" + case strings.Contains(upper, "_3DES_"), strings.Contains(upper, "_DES_"): + return "3DES/DES" + } + return "" +} + +func (r *weakCipherRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{emptyCaseState("tls.enum.ciphers.no_endpoints")} + } + if !hasEnum(data) { + return []sdk.CheckState{unknownState( + "tls.enum.ciphers.skipped", + "TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).", + )} + } + + var out []sdk.CheckState + anyEnum := false + for _, ref := range sortedRefs(data) { + p := data.Probes[ref] + if p.Enum == nil || len(p.Enum.Versions) == 0 { + continue + } + anyEnum = true + + // Aggregate by category so a server accepting six EXPORT suites + // produces one finding, not six. + byCategory := map[string][]string{} + for _, v := range p.Enum.Versions { + for _, c := range v.Ciphers { + cat := classifyCipher(c.Name) + if cat == "" { + continue + } + byCategory[cat] = append(byCategory[cat], c.Name) + } + } + if len(byCategory) == 0 { + continue + } + cats := make([]string, 0, len(byCategory)) + for c := range byCategory { + cats = append(cats, c) + } + sort.Strings(cats) + parts := make([]string, 0, len(cats)) + for _, c := range cats { + parts = append(parts, fmt.Sprintf("%s (%d)", c, len(byCategory[c]))) + } + meta := metaOf(p) + meta["weak_ciphers"] = byCategory + out = append(out, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "tls.enum.ciphers.weak_accepted", + Subject: subjectOf(p), + Message: "Endpoint accepts broken cipher suites: " + strings.Join(parts, ", ") + ".", + Meta: meta, + }) + } + if !anyEnum { + return []sdk.CheckState{unknownState( + "tls.enum.ciphers.skipped", + "No endpoint produced enumeration data.", + )} + } + if len(out) == 0 { + return []sdk.CheckState{passState( + "tls.enum.ciphers.ok", + "No endpoint accepts a known-broken cipher suite (NULL/anonymous/EXPORT/RC4/3DES).", + )} + } + return out +} diff --git a/checker/rules_enumeration_test.go b/checker/rules_enumeration_test.go new file mode 100644 index 0000000..3097477 --- /dev/null +++ b/checker/rules_enumeration_test.go @@ -0,0 +1,135 @@ +package checker + +import ( + "context" + "crypto/tls" + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// stubObs is a minimal ObservationGetter that serves a pre-built TLSData +// payload and ignores related lookups. It is local to this file rather than +// promoted to a shared helper to keep the rule tests self-contained. +type stubObs struct{ data TLSData } + +func (s stubObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + if key != ObservationKeyTLSProbes { + return nil + } + raw, _ := json.Marshal(s.data) + return json.Unmarshal(raw, dest) +} +func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +func newProbeWithEnum(versions ...EnumVersion) TLSProbe { + return TLSProbe{ + Host: "example.test", Port: 443, Endpoint: "example.test:443", Type: "tls", + TLSHandshakeOK: true, TLSVersionNum: tls.VersionTLS13, + Enum: &TLSEnumeration{Versions: versions}, + } +} + +func TestVersionEnumerationRule_Skipped_NoEnum(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{"a": {Host: "x", Port: 443, Endpoint: "x:443", Type: "tls", TLSHandshakeOK: true}}, + CollectedAt: time.Now(), + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Code != "tls.enum.versions.skipped" { + t.Fatalf("want a single skipped state, got %+v", got) + } +} + +func TestVersionEnumerationRule_OK_OnlyModern(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"}, + EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3"}, + ), + }, + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.versions.ok" { + t.Fatalf("want a single OK state, got %+v", got) + } +} + +func TestVersionEnumerationRule_LegacyAccepted(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS10, Name: "TLS 1.0"}, + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"}, + ), + }, + }} + got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.versions.legacy_accepted" { + t.Fatalf("want a single warn state, got %+v", got) + } + if !strings.Contains(got[0].Message, "TLS 1.0") { + t.Fatalf("warn message should mention the legacy version, got %q", got[0].Message) + } +} + +func TestClassifyCipher(t *testing.T) { + cases := map[string]string{ + "TLS_RSA_WITH_NULL_SHA": "NULL", + "TLS_DH_anon_WITH_AES_128_CBC_SHA": "anonymous", + "TLS_RSA_EXPORT_WITH_RC4_40_MD5": "EXPORT", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA": "RC4", + "TLS_RSA_WITH_3DES_EDE_CBC_SHA": "3DES/DES", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "", + "TLS_AES_256_GCM_SHA384": "", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "", + } + for name, want := range cases { + if got := classifyCipher(name); got != want { + t.Errorf("classifyCipher(%q) = %q, want %q", name, got, want) + } + } +} + +func TestWeakCipherRule_Detects(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2", Ciphers: []EnumCipher{ + {ID: 0xC02F, Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}, + {ID: 0x000A, Name: "TLS_RSA_WITH_3DES_EDE_CBC_SHA"}, + {ID: 0x0005, Name: "TLS_RSA_WITH_RC4_128_SHA"}, + }}, + ), + }, + }} + got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.ciphers.weak_accepted" { + t.Fatalf("want a single weak warn state, got %+v", got) + } + if !strings.Contains(got[0].Message, "RC4") || !strings.Contains(got[0].Message, "3DES/DES") { + t.Fatalf("warn message should list the broken categories, got %q", got[0].Message) + } +} + +func TestWeakCipherRule_OK_OnlyModern(t *testing.T) { + obs := stubObs{data: TLSData{ + Probes: map[string]TLSProbe{ + "a": newProbeWithEnum( + EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3", Ciphers: []EnumCipher{ + {ID: 0x1301, Name: "TLS_AES_128_GCM_SHA256"}, + }}, + ), + }, + }} + got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil) + if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.ciphers.ok" { + t.Fatalf("want a single OK state, got %+v", got) + } +} diff --git a/checker/starttls.go b/checker/starttls.go index 892e533..b06adf6 100644 --- a/checker/starttls.go +++ b/checker/starttls.go @@ -48,3 +48,18 @@ var starttlsUpgraders = map[string]starttlsUpgrader{} func registerStartTLS(protocol string, upgrader starttlsUpgrader) { starttlsUpgraders[protocol] = upgrader } + +// upgraderFor returns a tlsenum-compatible upgrader callback for a given +// STARTTLS dialect, plus an ok flag. An empty dialect means direct TLS and +// returns (nil, true) — tlsenum will skip the upgrade phase. An unknown +// dialect returns (nil, false) so the caller can record the skip reason. +func upgraderFor(dialect, sni string) (func(net.Conn) error, bool) { + if dialect == "" { + return nil, true + } + up, ok := starttlsUpgraders[dialect] + if !ok { + return nil, false + } + return func(c net.Conn) error { return up(c, sni) }, true +} diff --git a/checker/starttls_ldap.go b/checker/starttls_ldap.go index 30b47d3..40d1c5d 100644 --- a/checker/starttls_ldap.go +++ b/checker/starttls_ldap.go @@ -52,7 +52,10 @@ func starttlsLDAP(conn net.Conn, sni string) error { if err != nil { return fmt.Errorf("read response length: %w", err) } - if length <= 0 || length > 4096 { + // 16 KiB comfortably accommodates an ExtendedResponse with a verbose + // diagnosticMessage while still bounding memory against a hostile peer. + const maxLDAPResponseBytes = 16 * 1024 + if length <= 0 || length > maxLDAPResponseBytes { return fmt.Errorf("unreasonable LDAP response length %d", length) } body := make([]byte, length) diff --git a/checker/starttls_test.go b/checker/starttls_test.go index 9d05e8f..0f76ebd 100644 --- a/checker/starttls_test.go +++ b/checker/starttls_test.go @@ -132,6 +132,24 @@ func TestStartTLS_IMAP_OK(t *testing.T) { } } +func TestStartTLS_IMAP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n") + _, _ = readLineCRLF(br) + _, _ = io.WriteString(c, "* CAPABILITY IMAP4rev1 STARTTLS\r\nA001 OK CAPABILITY completed\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "A002 NO STARTTLS unavailable\r\n") + return err + }) + if err == nil { + t.Fatal("expected refusal error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("refusal should not be classified as not-offered: %v", err) + } +} + func TestStartTLS_IMAP_NotAdvertised(t *testing.T) { err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error { br := bufio.NewReader(c) @@ -185,6 +203,24 @@ func TestStartTLS_POP3_NotAdvertised(t *testing.T) { } } +func TestStartTLS_POP3_Refused(t *testing.T) { + err := runStartTLS(t, starttlsPOP3, "pop.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + _, _ = io.WriteString(c, "+OK POP3 ready\r\n") + _, _ = readLineCRLF(br) + _, _ = io.WriteString(c, "+OK capa list\r\nUSER\r\nSTLS\r\n.\r\n") + _, _ = readLineCRLF(br) + _, err := io.WriteString(c, "-ERR STLS unavailable\r\n") + return err + }) + if err == nil { + t.Fatal("expected refusal error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("refusal should not be classified as not-offered: %v", err) + } +} + func TestStartTLS_XMPP_OK(t *testing.T) { err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { br := bufio.NewReader(c) @@ -225,6 +261,47 @@ func TestStartTLS_XMPP_NotAdvertised(t *testing.T) { } } +func TestStartTLS_XMPP_Refused(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, _ = io.WriteString(c, + ``+ + ``) + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, ``) + return err + }) + if err == nil { + t.Fatal("expected failure error") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf(" should not be classified as not-offered: %v", err) + } +} + +func TestStartTLS_XMPP_StreamError(t *testing.T) { + err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error { + br := bufio.NewReader(c) + buf := make([]byte, 1024) + if _, err := br.Read(buf); err != nil { + return err + } + _, err := io.WriteString(c, + ``+ + ``) + return err + }) + if err == nil { + t.Fatal("expected stream:error to surface as error") + } +} + func TestStartTLS_LDAP_OK(t *testing.T) { err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { // Drain the StartTLS request (fixed 31 bytes: 0x30 0x1d + 29 bytes). @@ -250,6 +327,86 @@ func TestStartTLS_LDAP_OK(t *testing.T) { } } +func TestStartTLS_LDAP_WrongTag(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + _, err := c.Write([]byte{0x42, 0x00}) + return err + }) + if err == nil { + t.Fatal("expected error for wrong tag") + } + if errors.Is(err, errStartTLSNotOffered) { + t.Fatalf("malformed response should not be classified as not-offered: %v", err) + } +} + +func TestStartTLS_LDAP_OversizedLength(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // SEQUENCE with long-form length = 0x10000 (64 KiB) — beyond our 16 KiB cap. + _, err := c.Write([]byte{0x30, 0x83, 0x01, 0x00, 0x00}) + return err + }) + if err == nil { + t.Fatal("expected oversized-length error") + } +} + +func TestStartTLS_LDAP_TruncatedBody(t *testing.T) { + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // Announce 12 bytes of body, only send 5 then close. + _, err := c.Write([]byte{0x30, 0x0c, 0x02, 0x01, 0x01, 0x78, 0x07}) + return err + }) + if err == nil { + t.Fatal("expected error on truncated body") + } +} + +func TestStartTLS_LDAP_DiagnosticMessageOver4KiB(t *testing.T) { + // A real-world response with a verbose diagnosticMessage can exceed the + // previous 4 KiB cap. Confirm the bumped 16 KiB cap accepts it. + const diagLen = 8000 + diag := make([]byte, diagLen) + for i := range diag { + diag[i] = 'x' + } + err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { + req := make([]byte, 31) + if _, err := io.ReadFull(c, req); err != nil { + return err + } + // Body: messageID(3) + extResp tag(1) + extResp len(3) + resultCode(3) + matchedDN(2) + diag tag+long-len(4) + diag bytes + // extResp inner length = resultCode(3) + matchedDN(2) + diagTLV(4+diagLen) = 9 + diagLen + extInner := 9 + diagLen + // Outer SEQUENCE inner length = messageID(3) + extResp TLV(1+3+extInner) + outerInner := 3 + 4 + extInner + buf := []byte{0x30, 0x82, byte(outerInner >> 8), byte(outerInner & 0xff)} + buf = append(buf, 0x02, 0x01, 0x01) // messageID + buf = append(buf, 0x78, 0x82, byte(extInner>>8), byte(extInner&0xff)) + buf = append(buf, 0x0a, 0x01, 0x00) // resultCode = success + buf = append(buf, 0x04, 0x00) // matchedDN "" + buf = append(buf, 0x04, 0x82, byte(diagLen>>8), byte(diagLen&0xff)) + buf = append(buf, diag...) + _, err := c.Write(buf) + return err + }) + if err != nil { + t.Fatalf("expected success with verbose diagnosticMessage, got: %v", err) + } +} + func TestStartTLS_LDAP_Refused(t *testing.T) { err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error { req := make([]byte, 31) diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go index 22b421a..7b6e1ad 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -22,13 +22,20 @@ func starttlsXMPPServer(conn net.Conn, sni string) error { return starttlsXMPP(conn, sni, "jabber:server") } +// xmppPreTLSReadLimit caps the bytes the XML decoder may pull from an +// untrusted peer before the TLS handshake. The legitimate pre-TLS exchange +// ( opening + + ) is well under +// 1 KiB; 64 KiB is generous for non-malicious servers while bounding memory +// against a peer that streams unbounded XML to exhaust the prober. +const xmppPreTLSReadLimit = 64 * 1024 + func starttlsXMPP(conn net.Conn, sni, ns string) error { header := fmt.Sprintf(``, ns, sni) if _, err := io.WriteString(conn, header); err != nil { return fmt.Errorf("write stream header: %w", err) } - dec := xml.NewDecoder(conn) + dec := xml.NewDecoder(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit}) // Read the inbound opening and its . // A peer that opens with (or anything other than features) diff --git a/checker/types.go b/checker/types.go index 8cbf23d..a0d5389 100644 --- a/checker/types.go +++ b/checker/types.go @@ -9,8 +9,9 @@ const ObservationKeyTLSProbes = "tls_probes" // Option ids on CheckerOptions. const ( - OptionEndpoints = "endpoints" - OptionProbeTimeoutMs = "probeTimeoutMs" + OptionEndpoints = "endpoints" + OptionProbeTimeoutMs = "probeTimeoutMs" + OptionEnumerateCiphers = "enumerateCiphers" ) // Defaults shared between the definition's Default field and the runtime @@ -100,6 +101,12 @@ type TLSProbe struct { Chain []CertInfo `json:"chain,omitempty"` ElapsedMS int64 `json:"elapsed_ms,omitempty"` + // Enum carries the protocol-version and cipher-suite sweep. It is only + // populated when the user enables OptionEnumerateCiphers. Direct TLS and + // supported STARTTLS dialects are both swept; a STARTTLS endpoint with + // an unknown dialect is skipped with a reason recorded in Enum.Skipped. + Enum *TLSEnumeration `json:"enum,omitempty"` + // Error is a compatibility summary of whichever raw error applies. // Left for any external consumer still inspecting it; rules should // look at TCPError / HandshakeError instead. @@ -142,3 +149,31 @@ type CertInfo struct { const ( ExpiringSoonThreshold = 14 * 24 * time.Hour ) + +// TLSEnumeration is the result of sweeping a (version × cipher) matrix +// against an endpoint. The exact set the server accepts (rather than just the +// one combination it negotiated under default Go preferences) lets rules flag +// legacy versions and weak cipher suites that would otherwise stay invisible. +type TLSEnumeration struct { + // Versions lists every protocol version for which at least one cipher + // was accepted, with the matching cipher suites. + Versions []EnumVersion `json:"versions,omitempty"` + // Skipped is set when enumeration was not attempted (e.g. STARTTLS + // endpoint, prior handshake failure). Empty when enumeration ran. + Skipped string `json:"skipped,omitempty"` + // DurationMS is the wall-clock time spent enumerating, for ops visibility. + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// EnumVersion is one accepted protocol version plus the ciphers it accepted. +type EnumVersion struct { + Version uint16 `json:"version"` + Name string `json:"name"` + Ciphers []EnumCipher `json:"ciphers,omitempty"` +} + +// EnumCipher is one accepted cipher suite. +type EnumCipher struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} diff --git a/checker/upgrader_for_test.go b/checker/upgrader_for_test.go new file mode 100644 index 0000000..3803649 --- /dev/null +++ b/checker/upgrader_for_test.go @@ -0,0 +1,91 @@ +package checker + +import ( + "errors" + "net" + "testing" +) + +// TestUpgraderFor_DirectTLS verifies that an empty dialect returns a nil +// upgrader with ok=true: tlsenum's contract is that nil means "no upgrade +// phase", so direct-TLS endpoints must round-trip through this branch +// without producing a shim that would call into the registry. +func TestUpgraderFor_DirectTLS(t *testing.T) { + up, ok := upgraderFor("", "example.test") + if !ok { + t.Fatalf("expected ok=true for empty dialect") + } + if up != nil { + t.Fatalf("expected nil upgrader for empty dialect, got %T", up) + } +} + +func TestUpgraderFor_UnknownDialect(t *testing.T) { + up, ok := upgraderFor("totally-not-a-dialect", "example.test") + if ok { + t.Fatalf("expected ok=false for unknown dialect") + } + if up != nil { + t.Fatalf("expected nil upgrader for unknown dialect, got %T", up) + } +} + +// TestUpgraderFor_KnownDialect_ForwardsSNI registers a temporary fake dialect +// in the registry, asks upgraderFor for its callback, invokes the callback, +// and asserts the registered upgrader received the expected SNI. We can't +// reuse a real dialect for this because they all read/write protocol-specific +// banners on the connection — the point of this test is the SNI plumbing in +// the closure, not the dialect's own behavior. +func TestUpgraderFor_KnownDialect_ForwardsSNI(t *testing.T) { + const dialect = "test-fake" + const wantSNI = "host.example.test" + + var ( + gotSNI string + gotConn net.Conn + ) + wantErr := errors.New("sentinel from fake upgrader") + registerStartTLS(dialect, func(c net.Conn, sni string) error { + gotConn = c + gotSNI = sni + return wantErr + }) + defer delete(starttlsUpgraders, dialect) + + up, ok := upgraderFor(dialect, wantSNI) + if !ok || up == nil { + t.Fatalf("expected non-nil upgrader and ok=true, got nil=%v ok=%v", up == nil, ok) + } + + // Use a closed pipe end as a sentinel net.Conn — the registered upgrader + // captures it without doing I/O, so a real connection is unnecessary. + a, b := net.Pipe() + _ = a.Close() + _ = b.Close() + + if err := up(a); !errors.Is(err, wantErr) { + t.Fatalf("expected sentinel error to propagate, got %v", err) + } + if gotSNI != wantSNI { + t.Fatalf("registered upgrader received SNI %q, want %q", gotSNI, wantSNI) + } + if gotConn != a { + t.Fatalf("registered upgrader received a different conn than the one passed in") + } +} + +// TestUpgraderFor_RealDialects_AllRegistered guards against silently dropping +// a dialect from the registry: every protocol referenced by the contract's +// STARTTLS values must resolve to a non-nil upgrader. The list mirrors the +// dialects implemented in starttls_*.go. +func TestUpgraderFor_RealDialects_AllRegistered(t *testing.T) { + dialects := []string{"smtp", "submission", "imap", "pop3", "xmpp-client", "xmpp-server", "ldap"} + for _, d := range dialects { + t.Run(d, func(t *testing.T) { + up, ok := upgraderFor(d, "host.example") + if !ok || up == nil { + t.Fatalf("dialect %q is not registered", d) + } + }) + } +} diff --git a/go.mod b/go.mod index 9656a31..c23c67d 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,11 @@ module git.happydns.org/checker-tls go 1.25.0 require git.happydns.org/checker-sdk-go v1.5.0 + +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/go.sum b/go.sum index c389c68..c811775 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/tlsenum/ciphers.go b/tlsenum/ciphers.go new file mode 100644 index 0000000..303bfdd --- /dev/null +++ b/tlsenum/ciphers.go @@ -0,0 +1,103 @@ +package tlsenum + +// CipherSuite pairs an IANA TLS cipher suite ID with its standard name. +// +// The catalog below intentionally covers the "real-world" set: modern AEAD +// suites used by TLS 1.2/1.3, plus a long tail of legacy CBC/RC4/3DES/EXPORT +// suites we want to *detect* on remote servers (so we can flag them), even +// though Go's stdlib refuses to negotiate them. utls lets us put any 16-bit +// value in the offered list, so the server's accept/reject decision is the +// source of truth. +type CipherSuite struct { + ID uint16 + Name string + // TLS13 is true for the five TLS 1.3 AEAD suites; those must only be + // offered with TLS 1.3 ClientHellos. + TLS13 bool +} + +// TLS13Ciphers are the AEAD suites defined for TLS 1.3 (RFC 8446 §B.4). +var TLS13Ciphers = []CipherSuite{ + {0x1301, "TLS_AES_128_GCM_SHA256", true}, + {0x1302, "TLS_AES_256_GCM_SHA384", true}, + {0x1303, "TLS_CHACHA20_POLY1305_SHA256", true}, + {0x1304, "TLS_AES_128_CCM_SHA256", true}, + {0x1305, "TLS_AES_128_CCM_8_SHA256", true}, +} + +// LegacyCiphers covers TLS 1.0/1.1/1.2 (and SSLv3) suites. Not exhaustive of +// the IANA registry, but it includes everything any modern audit cares about: +// ECDHE/DHE/RSA/PSK kex, AES-GCM/CCM/CBC, ChaCha20, 3DES, RC4, NULL, EXPORT, +// anonymous, and a handful of GOST/CAMELLIA/ARIA entries seen in the wild. +var LegacyCiphers = []CipherSuite{ + // ECDHE-ECDSA + {0xC02B, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", false}, + {0xC02C, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCA9, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0xC023, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", false}, + {0xC024, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", false}, + {0xC009, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", false}, + {0xC00A, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", false}, + {0xC008, "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", false}, + {0xC007, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", false}, + {0xC006, "TLS_ECDHE_ECDSA_WITH_NULL_SHA", false}, + + // ECDHE-RSA + {0xC02F, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", false}, + {0xC030, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCA8, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0xC027, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", false}, + {0xC028, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", false}, + {0xC013, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", false}, + {0xC014, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", false}, + {0xC012, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", false}, + {0xC011, "TLS_ECDHE_RSA_WITH_RC4_128_SHA", false}, + {0xC010, "TLS_ECDHE_RSA_WITH_NULL_SHA", false}, + + // DHE-RSA + {0x009E, "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", false}, + {0x009F, "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", false}, + {0xCCAA, "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false}, + {0x0067, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", false}, + {0x006B, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", false}, + {0x0033, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", false}, + {0x0039, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", false}, + {0x0016, "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", false}, + + // Plain RSA + {0x009C, "TLS_RSA_WITH_AES_128_GCM_SHA256", false}, + {0x009D, "TLS_RSA_WITH_AES_256_GCM_SHA384", false}, + {0x003C, "TLS_RSA_WITH_AES_128_CBC_SHA256", false}, + {0x003D, "TLS_RSA_WITH_AES_256_CBC_SHA256", false}, + {0x002F, "TLS_RSA_WITH_AES_128_CBC_SHA", false}, + {0x0035, "TLS_RSA_WITH_AES_256_CBC_SHA", false}, + {0x000A, "TLS_RSA_WITH_3DES_EDE_CBC_SHA", false}, + {0x0005, "TLS_RSA_WITH_RC4_128_SHA", false}, + {0x0004, "TLS_RSA_WITH_RC4_128_MD5", false}, + {0x003B, "TLS_RSA_WITH_NULL_SHA256", false}, + {0x0002, "TLS_RSA_WITH_NULL_SHA", false}, + {0x0001, "TLS_RSA_WITH_NULL_MD5", false}, + + // Anonymous (broken by design — flag if seen) + {0x006D, "TLS_DH_anon_WITH_AES_256_CBC_SHA256", false}, + {0x0034, "TLS_DH_anon_WITH_AES_128_CBC_SHA", false}, + {0x003A, "TLS_DH_anon_WITH_AES_256_CBC_SHA", false}, + {0xC018, "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", false}, + {0xC019, "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", false}, + + // EXPORT (40-bit, illegal since ~2000 — flag if seen) + {0x0008, "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0014, "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0017, "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", false}, + {0x0019, "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", false}, + {0x0003, "TLS_RSA_EXPORT_WITH_RC4_40_MD5", false}, + {0x0006, "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", false}, +} + +// AllCiphers concatenates legacy and TLS 1.3 cipher suites. +func AllCiphers() []CipherSuite { + out := make([]CipherSuite, 0, len(LegacyCiphers)+len(TLS13Ciphers)) + out = append(out, LegacyCiphers...) + out = append(out, TLS13Ciphers...) + return out +} diff --git a/tlsenum/tlsenum.go b/tlsenum/tlsenum.go new file mode 100644 index 0000000..b2a7562 --- /dev/null +++ b/tlsenum/tlsenum.go @@ -0,0 +1,283 @@ +// Package tlsenum probes a remote endpoint to discover the exact set of +// SSL/TLS protocol versions and cipher suites it accepts. +// +// The Go stdlib's crypto/tls only negotiates a curated subset of modern +// suites and refuses to even offer legacy ones (RC4, 3DES, EXPORT, NULL, +// anonymous, …), so it cannot be used to *audit* what a server accepts. +// Instead we use github.com/refraction-networking/utls to craft a fully +// custom ClientHello carrying a single (version, cipher) pair and let the +// server tell us — by ServerHello or alert — whether it accepts it. +// +// Scope of the minimal version: +// - TLS 1.0, 1.1, 1.2, 1.3 (negotiated via the SupportedVersions extension). +// - Direct TLS only; STARTTLS upgrade is the caller's responsibility for +// now (the existing checker package owns those dialect handlers). +// - SSLv3 and SSLv2 are deliberately out of scope; SSLv2 has a different +// wire format and would require either raw byte crafting or a legacy +// OpenSSL sidecar. +package tlsenum + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "time" + + utls "github.com/refraction-networking/utls" +) + +// AllVersions is the set of protocol versions Probe knows how to offer. +var AllVersions = []uint16{ + utls.VersionTLS10, + utls.VersionTLS11, + utls.VersionTLS12, + utls.VersionTLS13, +} + +// VersionName returns a human-readable label for a TLS protocol version. +func VersionName(v uint16) string { + switch v { + case utls.VersionTLS10: + return "TLS 1.0" + case utls.VersionTLS11: + return "TLS 1.1" + case utls.VersionTLS12: + return "TLS 1.2" + case utls.VersionTLS13: + return "TLS 1.3" + default: + return "0x" + strconv.FormatUint(uint64(v), 16) + } +} + +// ProbeResult is the outcome of a single (version, cipher) attempt. +type ProbeResult struct { + OfferedVersion uint16 + OfferedCipher uint16 + + // Accepted is true when the server completed enough of the handshake to + // echo back a ServerHello with our offered version and cipher. We do not + // require a fully successful handshake (certificate verification can fail + // for unrelated reasons); ServerHello acceptance is what we measure. + Accepted bool + + // NegotiatedVersion / NegotiatedCipher are populated when Accepted is + // true. They should match the offered values; if they differ, the server + // is misbehaving (or downgrading). + NegotiatedVersion uint16 + NegotiatedCipher uint16 + + // Err is the underlying error from the dial or handshake. For a clean + // "server rejected this combination" outcome it will typically be a TLS + // alert (handshake_failure, protocol_version, insufficient_security…). + Err error +} + +// ProbeOptions controls a single Probe call. +type ProbeOptions struct { + // Timeout bounds dial + (optional) upgrade + handshake. A zero value + // means no deadline beyond the parent context's. + Timeout time.Duration + + // Upgrader, when non-nil, is invoked on the freshly-dialed connection + // before the TLS ClientHello is sent. It is the injection point for + // STARTTLS dialect handlers (SMTP, IMAP, POP3, …): the callback drives + // the plaintext exchange that requests the upgrade and returns nil once + // the connection is ready for tls.Client. tlsenum stays agnostic of the + // dialect; the caller owns that knowledge. + Upgrader func(net.Conn) error +} + +// Probe attempts a TLS handshake against addr offering exactly one protocol +// version and one cipher suite. It never panics; transport / handshake errors +// are reported on the returned ProbeResult. +// +// addr must be host:port. sni is the SNI to send (pass the host if unsure). +func Probe(ctx context.Context, addr, sni string, version, cipher uint16, opts ProbeOptions) ProbeResult { + res := ProbeResult{OfferedVersion: version, OfferedCipher: cipher} + + dialCtx := ctx + if opts.Timeout > 0 { + var cancel context.CancelFunc + dialCtx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + d := &net.Dialer{} + raw, err := d.DialContext(dialCtx, "tcp", addr) + if err != nil { + res.Err = fmt.Errorf("dial: %w", err) + return res + } + defer raw.Close() + if dl, ok := dialCtx.Deadline(); ok { + _ = raw.SetDeadline(dl) + } + + if opts.Upgrader != nil { + if err := opts.Upgrader(raw); err != nil { + res.Err = fmt.Errorf("upgrade: %w", err) + return res + } + } + + cfg := &utls.Config{ + ServerName: sni, + InsecureSkipVerify: true, // #nosec G402 -- enumeration; we only care about handshake outcome + } + uc := utls.UClient(raw, cfg, utls.HelloCustom) + spec := buildSpec(version, cipher, sni) + if err := uc.ApplyPreset(&spec); err != nil { + res.Err = fmt.Errorf("apply-preset: %w", err) + return res + } + + err = uc.Handshake() + state := uc.ConnectionState() + if err == nil { + res.Accepted = true + res.NegotiatedVersion = state.Version + res.NegotiatedCipher = state.CipherSuite + return res + } + + // Some servers complete ServerHello (so we know they accepted version + + // cipher) but fail later — for example, certificate-mismatch or the + // client failing to verify. If state has a non-zero Version/CipherSuite + // matching what we offered, we still count it as accepted. + if state.Version == version && state.CipherSuite == cipher && state.CipherSuite != 0 { + res.Accepted = true + res.NegotiatedVersion = state.Version + res.NegotiatedCipher = state.CipherSuite + } + res.Err = err + return res +} + +// EnumerateOptions controls Enumerate. +type EnumerateOptions struct { + // Timeout for each individual probe. Defaults to 5s when zero. + ProbeTimeout time.Duration + // Versions to try. Defaults to AllVersions when nil. + Versions []uint16 + // Ciphers to try. Defaults to AllCiphers() when nil. The TLS13 flag is + // honored: TLS 1.3 ciphers are only offered with TLS 1.3 probes, and + // vice-versa. + Ciphers []CipherSuite + // Upgrader, when non-nil, is forwarded to every sub-probe (see + // ProbeOptions.Upgrader). It is invoked on a freshly-dialed connection + // before each ClientHello, so STARTTLS dialect handlers run once per + // probe, not once for the whole sweep. + Upgrader func(net.Conn) error +} + +// EnumerationResult is the aggregate outcome of an enumeration sweep. +type EnumerationResult struct { + // SupportedVersions lists protocol versions for which at least one + // cipher was accepted. + SupportedVersions []uint16 + // CiphersByVersion lists, per accepted version, the cipher suites the + // server agreed to negotiate. + CiphersByVersion map[uint16][]CipherSuite +} + +// Enumerate sweeps a (version × cipher) matrix against addr and returns what +// the server actually accepts. Probes are performed sequentially; concurrency +// can be added later but tends to upset some middleboxes when probing too +// hard. +func Enumerate(ctx context.Context, addr, sni string, opts EnumerateOptions) (EnumerationResult, error) { + if opts.ProbeTimeout == 0 { + opts.ProbeTimeout = 5 * time.Second + } + versions := opts.Versions + if versions == nil { + versions = AllVersions + } + ciphers := opts.Ciphers + if ciphers == nil { + ciphers = AllCiphers() + } + + out := EnumerationResult{ + CiphersByVersion: make(map[uint16][]CipherSuite), + } + seenVersion := make(map[uint16]bool) + + for _, v := range versions { + isTLS13 := v == utls.VersionTLS13 + for _, c := range ciphers { + if c.TLS13 != isTLS13 { + continue + } + if err := ctx.Err(); err != nil { + return out, err + } + r := Probe(ctx, addr, sni, v, c.ID, ProbeOptions{ + Timeout: opts.ProbeTimeout, + Upgrader: opts.Upgrader, + }) + if !r.Accepted { + continue + } + out.CiphersByVersion[v] = append(out.CiphersByVersion[v], c) + if !seenVersion[v] { + seenVersion[v] = true + out.SupportedVersions = append(out.SupportedVersions, v) + } + } + } + return out, nil +} + +// buildSpec assembles a ClientHelloSpec offering exactly one cipher and one +// protocol version. For TLS 1.3 the legacy version field stays at TLS 1.2 and +// the real version is signalled through the SupportedVersions extension, per +// RFC 8446 §4.1.2 / §4.2.1. +func buildSpec(version, cipher uint16, sni string) utls.ClientHelloSpec { + tlsVersMin := version + tlsVersMax := version + if version == utls.VersionTLS13 { + // utls inspects TLSVersMax to decide whether to drive TLS 1.3 + // machinery; the on-the-wire legacy_version stays TLS 1.2. + tlsVersMin = utls.VersionTLS12 + } + + exts := []utls.TLSExtension{ + &utls.SNIExtension{ServerName: sni}, + &utls.SupportedCurvesExtension{Curves: []utls.CurveID{ + utls.X25519, utls.CurveP256, utls.CurveP384, utls.CurveP521, + }}, + &utls.SupportedPointsExtension{SupportedPoints: []byte{0}}, // uncompressed + &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []utls.SignatureScheme{ + utls.ECDSAWithP256AndSHA256, utls.ECDSAWithP384AndSHA384, utls.ECDSAWithP521AndSHA512, + utls.PSSWithSHA256, utls.PSSWithSHA384, utls.PSSWithSHA512, + utls.PKCS1WithSHA256, utls.PKCS1WithSHA384, utls.PKCS1WithSHA512, + utls.PKCS1WithSHA1, utls.ECDSAWithSHA1, + }}, + &utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient}, + } + + if version == utls.VersionTLS13 { + exts = append(exts, + &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13}}, + &utls.KeyShareExtension{KeyShares: []utls.KeyShare{ + {Group: utls.X25519}, + }}, + &utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}}, + ) + } + + return utls.ClientHelloSpec{ + TLSVersMin: tlsVersMin, + TLSVersMax: tlsVersMax, + CipherSuites: []uint16{cipher}, + CompressionMethods: []byte{0}, // null + Extensions: exts, + } +} + +// ErrNoVersions is returned when an enumeration request asks for an empty set +// of versions or ciphers. +var ErrNoVersions = errors.New("tlsenum: no versions or ciphers to probe") diff --git a/tlsenum/tlsenum_test.go b/tlsenum/tlsenum_test.go new file mode 100644 index 0000000..7d94670 --- /dev/null +++ b/tlsenum/tlsenum_test.go @@ -0,0 +1,223 @@ +package tlsenum + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + stdtls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "testing" + "time" + + utls "github.com/refraction-networking/utls" +) + +// selfSignedCert returns a brand-new in-memory self-signed cert + key for +// "test.local", suitable for stdlib tls.Server. +func selfSignedCert() (stdtls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return stdtls.Certificate{}, err + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.local"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"test.local"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + return stdtls.Certificate{}, err + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return stdtls.Certificate{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return stdtls.X509KeyPair(certPEM, keyPEM) +} + +// runFakeStartTLSServer accepts one connection, expects a "STARTTLS\r\n" +// line, replies "OK\r\n", then runs a TLS handshake. It returns once the +// handshake completes (or fails) and the connection is closed. +func runFakeStartTLSServer(ln net.Listener, cert stdtls.Certificate) error { + c, err := ln.Accept() + if err != nil { + return err + } + defer c.Close() + buf := make([]byte, len("STARTTLS\r\n")) + if _, err := io.ReadFull(c, buf); err != nil { + return err + } + if string(buf) != "STARTTLS\r\n" { + return fmt.Errorf("unexpected pre-tls line: %q", string(buf)) + } + if _, err := c.Write([]byte("OK\r\n")); err != nil { + return err + } + tc := stdtls.Server(c, &stdtls.Config{ + Certificates: []stdtls.Certificate{cert}, + MinVersion: stdtls.VersionTLS12, + }) + defer tc.Close() + return tc.Handshake() +} + +// liveTarget returns a host:port to enumerate against, or skips the test if +// the environment hasn't opted in. Network tests are gated behind +// TLSENUM_LIVE=1 so the unit-test suite stays hermetic. +func liveTarget(t *testing.T) (addr, sni string) { + t.Helper() + if os.Getenv("TLSENUM_LIVE") == "" { + t.Skip("set TLSENUM_LIVE=1 to run live enumeration tests") + } + host := os.Getenv("TLSENUM_HOST") + if host == "" { + host = "tls-v1-2.badssl.com" + } + port := os.Getenv("TLSENUM_PORT") + if port == "" { + port = "1012" + } + return net.JoinHostPort(host, port), host +} + +func TestProbe_TLS12_AESGCM(t *testing.T) { + addr, sni := liveTarget(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + r := Probe(ctx, addr, sni, utls.VersionTLS12, 0xC02F /* ECDHE-RSA-AES128-GCM-SHA256 */, ProbeOptions{Timeout: 5 * time.Second}) + if !r.Accepted { + t.Fatalf("expected ECDHE-RSA-AES128-GCM-SHA256 to be accepted on TLS 1.2 target; got err=%v", r.Err) + } + if r.NegotiatedVersion != utls.VersionTLS12 { + t.Fatalf("negotiated version = %x, want %x", r.NegotiatedVersion, utls.VersionTLS12) + } +} + +func TestEnumerate_BasicShape(t *testing.T) { + addr, sni := liveTarget(t) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + res, err := Enumerate(ctx, addr, sni, EnumerateOptions{ + ProbeTimeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("Enumerate: %v", err) + } + if len(res.SupportedVersions) == 0 { + t.Fatalf("no supported versions discovered") + } + for v, ciphers := range res.CiphersByVersion { + if len(ciphers) == 0 { + t.Errorf("version %s listed as supported but no ciphers recorded", VersionName(v)) + } + t.Logf("%s: %d cipher(s)", VersionName(v), len(ciphers)) + } +} + +// TestProbe_UpgraderInvoked uses a tiny in-memory STARTTLS-style server: a +// goroutine listens, reads one "STARTTLS\r\n" line, replies "OK\r\n", then +// performs a real Go-stdlib TLS handshake. We probe through the matching +// Upgrader and assert the handshake succeeds — proving the callback runs in +// the right place between dial and ClientHello. +func TestProbe_UpgraderInvoked(t *testing.T) { + cert, err := selfSignedCert() + if err != nil { + t.Fatalf("self-signed cert: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + srvDone := make(chan error, 1) + go func() { srvDone <- runFakeStartTLSServer(ln, cert) }() + + upgrader := func(c net.Conn) error { + if _, err := c.Write([]byte("STARTTLS\r\n")); err != nil { + return err + } + buf := make([]byte, 16) + n, err := c.Read(buf) + if err != nil { + return err + } + if got := string(buf[:n]); got != "OK\r\n" { + return fmt.Errorf("unexpected reply: %q", got) + } + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + r := Probe(ctx, ln.Addr().String(), "test.local", + utls.VersionTLS12, 0xC02B, /* ECDHE-ECDSA-AES128-GCM-SHA256 (matches the P-256 cert) */ + ProbeOptions{Timeout: 3 * time.Second, Upgrader: upgrader}) + if !r.Accepted { + t.Fatalf("expected handshake to succeed through upgrader; err=%v", r.Err) + } + if r.NegotiatedVersion != utls.VersionTLS12 { + t.Fatalf("negotiated %#x, want %#x", r.NegotiatedVersion, utls.VersionTLS12) + } + if err := <-srvDone; err != nil { + t.Logf("fake server done with: %v", err) // accept clean close from utls + } +} + +func TestProbe_UpgraderError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + wantErr := errors.New("plaintext refused starttls") + r := Probe(context.Background(), ln.Addr().String(), "x", + utls.VersionTLS12, 0xC02F, + ProbeOptions{Timeout: 2 * time.Second, Upgrader: func(net.Conn) error { return wantErr }}) + if r.Accepted { + t.Fatalf("expected probe to fail when upgrader returns error") + } + if r.Err == nil || !errors.Is(r.Err, wantErr) { + t.Fatalf("expected wrapped upgrader error, got %v", r.Err) + } +} + +func TestVersionName(t *testing.T) { + cases := map[uint16]string{ + utls.VersionTLS10: "TLS 1.0", + utls.VersionTLS11: "TLS 1.1", + utls.VersionTLS12: "TLS 1.2", + utls.VersionTLS13: "TLS 1.3", + 0x9999: "0x9999", + } + for v, want := range cases { + if got := VersionName(v); got != want { + t.Errorf("VersionName(%#x) = %q, want %q", v, got, want) + } + } +} From e4d1bc36a769917339fe28b72e3bbf0137a3127c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 30 Apr 2026 08:36:38 +0700 Subject: [PATCH 15/16] Update rules section --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 2d7e158..5cbad63 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,24 @@ existing downstream parsers. - `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 | From 7c2f4bfbb5050d3b5069e6cfa1144635ca1f2515 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 10 May 2026 19:10:38 +0800 Subject: [PATCH 16/16] Add CI/CD pipeline --- .drone-manifest.yml | 22 ++++++ .drone.yml | 187 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 .drone-manifest.yml create mode 100644 .drone.yml diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..462c6eb --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} +{{/if}} +manifests: + - image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + platform: + architecture: amd64 + os: linux + - image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + platform: + architecture: arm + os: linux + variant: v7 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..60c8449 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,187 @@ +--- +kind: pipeline +type: docker +name: build-amd64 + +platform: + os: linux + arch: amd64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-tls + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-tls + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +--- +kind: pipeline +type: docker +name: build-arm64 + +platform: + os: linux + arch: arm64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-tls + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-tls + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + event: + - cron + - push + - tag + +--- +kind: pipeline +name: docker-manifest + +platform: + os: linux + arch: arm64 + +steps: + - name: publish on Docker Hub + image: plugins/manifest + settings: + auto_tag: true + ignore_missing: true + spec: .drone-manifest.yml + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +depends_on: + - build-amd64 + - build-arm64