diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index 462c6eb..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 60c8449..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -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 diff --git a/Dockerfile b/Dockerfile index c4abb45..ea99603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . 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/Makefile b/Makefile index 8ebb85f..bf1467e 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 test clean +.PHONY: all plugin docker clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,8 +21,5 @@ $(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/README.md b/README.md index 5cbad63..f94547a 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,32 +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. - -## 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 | +- `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 @@ -162,38 +144,6 @@ 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 diff --git a/checker/collect.go b/checker/collect.go index d576fb3..ff8f60b 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -22,7 +22,6 @@ 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 { @@ -41,36 +40,15 @@ 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) - } - } + 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() diff --git a/checker/definition.go b/checker/definition.go index 4fd166a..31c996d 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -29,13 +29,6 @@ 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{ { @@ -47,7 +40,9 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{ + Rule(), + }, Interval: &sdk.CheckIntervalSpec{ Min: 6 * time.Hour, Max: 7 * 24 * time.Hour, diff --git a/checker/enumerate.go b/checker/enumerate.go deleted file mode 100644 index 61e76ef..0000000 --- a/checker/enumerate.go +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 02484bc..0000000 --- a/checker/enumerate_test.go +++ /dev/null @@ -1,198 +0,0 @@ -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/fetch.go b/checker/fetch.go deleted file mode 100644 index 1a078d1..0000000 --- a/checker/fetch.go +++ /dev/null @@ -1,108 +0,0 @@ -// 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, // #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) - } - 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 "" -} diff --git a/checker/interactive.go b/checker/interactive.go index 048b7a8..85a7dc3 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,5 +1,3 @@ -//go:build standalone - package checker import ( @@ -26,7 +24,7 @@ func starttlsChoices() []string { return protos } -// RenderForm satisfies server.Interactive. The fields mirror the inputs +// 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 { @@ -77,7 +75,7 @@ func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm satisfies server.Interactive. It turns the human inputs into +// 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. diff --git a/checker/prober.go b/checker/prober.go index 1aa25ee..fcbe88d 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -33,7 +33,6 @@ func buildChain(certs []*x509.Certificate) []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[:]), @@ -58,11 +57,8 @@ 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 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). +// 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, ".") @@ -73,13 +69,11 @@ 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, - RequireSTARTTLS: ep.RequireSTARTTLS, - STARTTLSDialect: ep.STARTTLS, + Host: host, + Port: ep.Port, + Endpoint: addr, + Type: probeTypeString(ep), + SNI: sni, } dialCtx, cancel := context.WithTimeout(ctx, timeout) @@ -88,8 +82,13 @@ 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,28 +100,23 @@ 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() - if ep.STARTTLS != "" && isStartTLSUnsupported(err) { - p.STARTTLSNotOffered = true - } - if errors.Is(err, errUnsupportedStartTLSProto) { - p.STARTTLSUnsupportedProto = true - } + p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) 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.NoPeerCert = true + 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 } @@ -135,16 +129,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 = leaf.DNSNames + p.DNSNames = append(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. 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. + // 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) @@ -157,8 +151,48 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) }) chainValid := verifyErr == nil p.ChainValid = &chainValid - if verifyErr != nil { - p.ChainVerifyErr = verifyErr.Error() + 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() @@ -167,12 +201,12 @@ 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 raw -// observation rather than aborting the handshake. +// 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, // #nosec G402 -- intentional: chain verified separately in probe() + InsecureSkipVerify: true, } if ep.STARTTLS == "" { @@ -185,7 +219,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("%w: %q", errUnsupportedStartTLSProto, ep.STARTTLS) + 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) @@ -197,10 +231,34 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e return tlsConn, nil } -var ( - errStartTLSNotOffered = errors.New("starttls not advertised by server") - errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol") -) +// 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 index ad7e288..4023e58 100644 --- a/checker/prober_test.go +++ b/checker/prober_test.go @@ -60,8 +60,11 @@ func TestProbe_TCPUnreachable(t *testing.T) { Port: uint16(addr.Port), }, 1*time.Second) - if probe.TCPError == "" { - t.Errorf("expected a TCP error for unreachable port") + 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) } } diff --git a/checker/rule.go b/checker/rule.go index ad9d626..310c283 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -8,83 +8,140 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// 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{}, - &versionEnumerationRule{}, - &weakCipherRule{}, - } +// 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{} } -// 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) { +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 nil, &sdk.CheckState{ + return []sdk.CheckState{{ Status: sdk.StatusError, - Message: fmt.Sprintf("failed to load tls_probes observation: %v", err), - Code: "tls.observation_error", - } + 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", + }} } - 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) - return refs + + out := make([]sdk.CheckState, 0, len(refs)) + for _, ref := range refs { + p := data.Probes[ref] + out = append(out, evaluateProbe(p)) + } + return out } -// 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, +// 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 != "" { - m["tls_version"] = 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, + } } - return m } -// 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.") +// 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/rules_certificate.go b/checker/rules_certificate.go deleted file mode 100644 index 2fc2f4c..0000000 --- a/checker/rules_certificate.go +++ /dev/null @@ -1,233 +0,0 @@ -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 deleted file mode 100644 index 738b235..0000000 --- a/checker/rules_discovery.go +++ /dev/null @@ -1,34 +0,0 @@ -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_enumeration.go b/checker/rules_enumeration.go deleted file mode 100644 index 6757be0..0000000 --- a/checker/rules_enumeration.go +++ /dev/null @@ -1,197 +0,0 @@ -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 deleted file mode 100644 index 3097477..0000000 --- a/checker/rules_enumeration_test.go +++ /dev/null @@ -1,135 +0,0 @@ -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/rules_handshake.go b/checker/rules_handshake.go deleted file mode 100644 index 2ee343c..0000000 --- a/checker/rules_handshake.go +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 6153b38..0000000 --- a/checker/rules_protocol.go +++ /dev/null @@ -1,120 +0,0 @@ -package checker - -import ( - "context" - "crypto/tls" - "fmt" - "sort" - "strings" - - 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")} - } - - // 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 - } - suites[p.CipherSuite]++ - endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint) - } - if len(suites) == 0 { - return []sdk.CheckState{unknownState( - "tls.cipher_suite.skipped", - "No endpoint completed a TLS handshake.", - )} - } - 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/rules_reachability.go b/checker/rules_reachability.go deleted file mode 100644 index 6540059..0000000 --- a/checker/rules_reachability.go +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index cea5cdc..0000000 --- a/checker/rules_starttls.go +++ /dev/null @@ -1,108 +0,0 @@ -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/starttls.go b/checker/starttls.go index b06adf6..8fb1edd 100644 --- a/checker/starttls.go +++ b/checker/starttls.go @@ -1,41 +1,6 @@ package checker -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) - } - } -} +import "net" // starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on // conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success @@ -48,18 +13,3 @@ 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_imap.go b/checker/starttls_imap.go index 4c04010..7a7a6fb 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 := readLineLimited(rw.Reader); err != nil { + if _, err := rw.ReadString('\n'); 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 fmt.Errorf("flush CAPABILITY: %w", err) + return err } supportsSTARTTLS := false for { - line, err := readLineLimited(rw.Reader) + line, err := rw.ReadString('\n') if err != nil { return fmt.Errorf("read CAPABILITY: %w", err) } @@ -36,10 +36,6 @@ 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 } } @@ -48,13 +44,13 @@ func starttlsIMAP(conn net.Conn, sni string) error { } if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil { - return fmt.Errorf("write STARTTLS: %w", err) + return err } if err := rw.Flush(); err != nil { - return fmt.Errorf("flush STARTTLS: %w", err) + return err } for { - line, err := readLineLimited(rw.Reader) + line, err := rw.ReadString('\n') if err != nil { return fmt.Errorf("read STARTTLS response: %w", err) } diff --git a/checker/starttls_ldap.go b/checker/starttls_ldap.go index 40d1c5d..30b47d3 100644 --- a/checker/starttls_ldap.go +++ b/checker/starttls_ldap.go @@ -52,10 +52,7 @@ func starttlsLDAP(conn net.Conn, sni string) error { if err != nil { return fmt.Errorf("read response length: %w", err) } - // 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 { + if length <= 0 || length > 4096 { return fmt.Errorf("unreasonable LDAP response length %d", length) } body := make([]byte, length) diff --git a/checker/starttls_pop3.go b/checker/starttls_pop3.go index f46414c..887933a 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 := readLineLimited(rw.Reader) + greeting, err := rw.ReadString('\n') 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 fmt.Errorf("write CAPA: %w", err) + return err } if err := rw.Flush(); err != nil { - return fmt.Errorf("flush CAPA: %w", err) + return err } - first, err := readLineLimited(rw.Reader) + 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 := readLineLimited(rw.Reader) + line, err := rw.ReadString('\n') 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 fmt.Errorf("write STLS: %w", err) + return err } if err := rw.Flush(); err != nil { - return fmt.Errorf("flush STLS: %w", err) + return err } - resp, err := readLineLimited(rw.Reader) + resp, err := rw.ReadString('\n') if err != nil { return fmt.Errorf("read STLS response: %w", err) } diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go index ccc0211..39db327 100644 --- a/checker/starttls_smtp.go +++ b/checker/starttls_smtp.go @@ -7,11 +7,6 @@ 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) @@ -25,7 +20,7 @@ func starttlsSMTP(conn net.Conn, sni string) error { return fmt.Errorf("read greeting: %w", err) } - if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil { + 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 { @@ -65,7 +60,7 @@ func readSMTPGreeting(r *bufio.Reader) error { func readSMTPResponse(r *bufio.Reader) ([]string, error) { var out []string for { - line, err := readLineLimited(r) + line, err := r.ReadString('\n') if err != nil { return out, err } diff --git a/checker/starttls_test.go b/checker/starttls_test.go deleted file mode 100644 index 0f76ebd..0000000 --- a/checker/starttls_test.go +++ /dev/null @@ -1,431 +0,0 @@ -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_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) - _, _ = 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_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) - // 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_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). - 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_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) - 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 7b6e1ad..d810654 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -22,66 +22,45 @@ 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(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit}) + 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 { tok, err := dec.Token() if err != nil { return fmt.Errorf("read stream features: %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 + 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) } - 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 + 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 + } } } } - case "error": - return fmt.Errorf("server returned before features") - default: - return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local) } } +doneFeatures: if !hasStartTLS { return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered) } diff --git a/checker/types.go b/checker/types.go index a0d5389..81e59bd 100644 --- a/checker/types.go +++ b/checker/types.go @@ -9,9 +9,8 @@ const ObservationKeyTLSProbes = "tls_probes" // Option ids on CheckerOptions. const ( - OptionEndpoints = "endpoints" - OptionProbeTimeoutMs = "probeTimeoutMs" - OptionEnumerateCiphers = "enumerateCiphers" + OptionEndpoints = "endpoints" + OptionProbeTimeoutMs = "probeTimeoutMs" ) // Defaults shared between the definition's Default field and the runtime @@ -23,67 +22,33 @@ 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. -// -// Only raw observation fields live here. Judgement (severity, pass/fail, -// human-facing messages) is derived from these fields by CheckRules. +// 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"` - - // 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"` + 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. @@ -91,26 +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"` - - // 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. - Error string `json:"error,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 @@ -128,10 +84,6 @@ type CertInfo struct { 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"` @@ -145,35 +97,10 @@ type CertInfo struct { SPKIDERBase64 string `json:"spki_der_base64,omitempty"` } -// Expiry thresholds shared by rules. -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"` +// 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/checker/upgrader_for_test.go b/checker/upgrader_for_test.go deleted file mode 100644 index 3803649..0000000 --- a/checker/upgrader_for_test.go +++ /dev/null @@ -1,91 +0,0 @@ -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/contract/contract.go b/contract/contract.go index 76f7e07..bc038b0 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -16,7 +16,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -59,27 +58,10 @@ 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) @@ -113,7 +95,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)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above + sum := sha1.Sum([]byte(canonical)) return hex.EncodeToString(sum[:8]) } @@ -127,9 +109,6 @@ 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 } @@ -144,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. diff --git a/go.mod b/go.mod index c23c67d..416eb47 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,4 @@ 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 -) +require git.happydns.org/checker-sdk-go v1.2.0 diff --git a/go.sum b/go.sum index c811775..272600a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,2 @@ -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= +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 index c967401..f781e37 100644 --- a/main.go +++ b/main.go @@ -4,28 +4,20 @@ import ( "flag" "log" - "git.happydns.org/checker-sdk-go/checker/server" + sdk "git.happydns.org/checker-sdk-go/checker" tls "git.happydns.org/checker-tls/checker" ) 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 { + 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 index 991152a..6648452 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -9,6 +9,5 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { tls.Version = Version - prvd := tls.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return tls.Definition(), tls.Provider(), nil } diff --git a/tlsenum/ciphers.go b/tlsenum/ciphers.go deleted file mode 100644 index 303bfdd..0000000 --- a/tlsenum/ciphers.go +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index b2a7562..0000000 --- a/tlsenum/tlsenum.go +++ /dev/null @@ -1,283 +0,0 @@ -// 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 deleted file mode 100644 index 7d94670..0000000 --- a/tlsenum/tlsenum_test.go +++ /dev/null @@ -1,223 +0,0 @@ -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) - } - } -}