commit ccc5b0cd9856e6964e439ae39246c95d120b344b Author: Pierre-Olivier Mercier Date: Tue Apr 21 21:50:49 2026 +0700 Initial commit 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 +}