Compare commits

...

12 commits

Author SHA1 Message Date
7c2f4bfbb5 Add CI/CD pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 19:32:13 +08:00
e4d1bc36a7 Update rules section 2026-04-30 08:36:38 +07:00
a9f37c79cf Add tlsenum package and add version/cipher enumeration into the checker
tlsenum package probes a remote endpoint with one ClientHello
per (version, cipher) pair via utls, so the checker can report the
exact set the server accepts rather than only the suite Go's stdlib
happens to negotiate. Probe accepts an Upgrader callback so STARTTLS
dialects plug in without tlsenum learning about them; the checker
bridges its existing dialect registry through upgraderFor.
2026-04-29 13:35:29 +07:00
8a7f9feaf7 Document usage of FetchChain, BuildChain, AutoSTARTTLS, and CertInfo 2026-04-29 13:33:27 +07:00
fa212f0fae Harden contract validation, STARTTLS edge cases, and rule output 2026-04-26 19:55:44 +07:00
a925e4f162 docker: add HEALTHCHECK probing /health
The binary doubles as its own healthcheck client via the SDK's
-healthcheck flag, so the probe works in the scratch image
(no shell, no curl, no wget).
2026-04-26 16:37:20 +07:00
ad32e1c757 Run container as non-root user
Add USER 65534:65534 to the scratch runtime image so the checker
process does not run as root.
2026-04-26 16:37:18 +07:00
c99c13a7e0 fix: Implement CheckerDefinitionProvider on tlsProvider
Lets the SDK seed OptionEndpoints from the primary's DiscoverEntries
output when checker-tls runs as a sibling.
2026-04-26 00:36:44 +07:00
e32633ca40 Harden STARTTLS handlers and add per-dialect tests
Bound line reads with readLineLimited to prevent a peer from exhausting
memory by withholding line terminators, wrap previously bare error
returns for consistent context, surface XML decoder Skip errors, and
replace the goto in the XMPP feature scan with a labeled break. New
starttls_test.go exercises SMTP/IMAP/POP3/XMPP/LDAP success and
not-advertised paths through net.Pipe-mocked servers.
2026-04-25 23:15:17 +07:00
4177fcdc7b Split monolithic rule into per-test rules, collect gathers facts only 2026-04-25 23:14:42 +07:00
5b71e85f49 Bump SDK to 1.4.0 2026-04-24 17:43:36 +07:00
79782a49c4 Migrate to checker-sdk-go v1.3.0 with standalone build tag
The SDK split the HTTP server scaffolding into the new
checker-sdk-go/checker/server subpackage. Update main.go to import
server and call server.New, and isolate the interactive form code
behind the standalone build tag so plugin/builtin builds skip
net/http entirely.
2026-04-24 14:04:55 +07:00
39 changed files with 3026 additions and 319 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-tls:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-tls
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -6,9 +6,12 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls .
FROM scratch FROM scratch
COPY --from=builder /checker-tls /checker-tls COPY --from=builder /checker-tls /checker-tls
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-tls", "-healthcheck"]
ENTRYPOINT ["/checker-tls"] ENTRYPOINT ["/checker-tls"]

View file

@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean .PHONY: all plugin docker test clean
all: $(CHECKER_NAME) all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES) $(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ . go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so plugin: $(CHECKER_NAME).so
@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
docker: docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean: clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

View file

@ -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 on the lineage side so that a consumer knows which probe corresponds to
which entry it originally published. which entry it originally published.
@ -129,14 +129,32 @@ existing downstream parsers.
## Issues reported ## Issues reported
- `tcp_unreachable` dial failed. - `tcp_unreachable`, dial failed.
- `handshake_failed` TLS handshake or STARTTLS upgrade failed. - `handshake_failed`, TLS handshake or STARTTLS upgrade failed.
- `starttls_not_offered` server didn't advertise STARTTLS. Severity is - `starttls_not_offered`, server didn't advertise STARTTLS. Severity is
`crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise. `crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise.
- `chain_invalid` — leaf does not chain to a system-trusted root. - `chain_invalid`, leaf does not chain to a system-trusted root.
- `hostname_mismatch` — cert SANs don't cover the SNI. - `hostname_mismatch`, cert SANs don't cover the SNI.
- `expired` / `expiring_soon` — cert expiry posture. - `expired` / `expiring_soon`, cert expiry posture.
- `weak_tls_version` — negotiated TLS < 1.2. - `weak_tls_version`, negotiated TLS < 1.2.
## Rules
| Code | Description | Severity |
|---------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
| `tls.endpoints_discovered` | Verifies that at least one TLS endpoint has been discovered for this target. | INFO |
| `tls.reachability` | Verifies that every discovered TLS endpoint accepts a TCP connection. | CRITICAL |
| `tls.handshake` | Verifies the TLS handshake completes on every reachable endpoint. | CRITICAL |
| `tls.starttls_advertised` | Verifies that STARTTLS endpoints advertise the upgrade capability. | CRITICAL |
| `tls.starttls_dialect_supported`| Verifies that discovered STARTTLS dialects are implemented by the checker. | CRITICAL |
| `tls.peer_certificate_present` | Verifies the server presented a certificate during the TLS handshake. | CRITICAL |
| `tls.chain_validity` | Verifies the presented certificate chain validates against the system trust store. | CRITICAL |
| `tls.hostname_match` | Verifies the leaf certificate covers the probed hostname (SNI). | CRITICAL |
| `tls.expiry` | Flags expired or soon-to-expire leaf certificates. | CRITICAL |
| `tls.version` | Flags endpoints negotiating a TLS version below the recommended TLS 1.2. | WARNING |
| `tls.cipher_suite` | Reports the cipher suite negotiated on each endpoint. | INFO |
| `tls.enum.versions` | Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option). | WARNING |
| `tls.enum.ciphers` | Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES). | WARNING |
## Options ## Options
@ -144,6 +162,38 @@ existing downstream parsers.
| ---------------- | ------ | ------- | -------------------------------------------- | | ---------------- | ------ | ------- | -------------------------------------------- |
| `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. | | `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 ## Running
```bash ```bash

View file

@ -22,6 +22,7 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
timeoutMs = DefaultProbeTimeoutMs timeoutMs = DefaultProbeTimeoutMs
} }
timeout := time.Duration(timeoutMs) * time.Millisecond timeout := time.Duration(timeoutMs) * time.Millisecond
enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false)
entries, warnings := contract.ParseEntries(raw) entries, warnings := contract.ParseEntries(raw)
for _, w := range warnings { for _, w := range warnings {
@ -40,15 +41,36 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProbes) sem := make(chan struct{}, MaxConcurrentProbes)
dispatch:
for _, e := range entries { for _, e := range entries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
break dispatch
}
wg.Add(1) wg.Add(1)
sem <- struct{}{}
go func() { go func() {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
pr := probe(ctx, e.Endpoint, timeout) pr := probe(ctx, e.Endpoint, timeout)
log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q", log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q",
pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error) pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error)
if enumerate && pr.TLSHandshakeOK {
enumRes, skipReason := enumerateEndpoint(ctx, e.Endpoint, enumerationBudget)
switch {
case enumRes != nil && enumRes.Skipped != "":
pr.Enum = enumRes
log.Printf("checker-tls: enum %s:%d → error: %s (duration=%dms)",
pr.Host, pr.Port, enumRes.Skipped, enumRes.DurationMS)
case enumRes != nil:
pr.Enum = enumRes
log.Printf("checker-tls: enum %s:%d → versions=%d duration=%dms",
pr.Host, pr.Port, len(enumRes.Versions), enumRes.DurationMS)
case skipReason != "":
log.Printf("checker-tls: enum %s:%d → skipped: %s",
pr.Host, pr.Port, skipReason)
}
}
mu.Lock() mu.Lock()
probes[e.Ref] = pr probes[e.Ref] = pr
mu.Unlock() mu.Unlock()

View file

@ -11,7 +11,7 @@ import (
var Version = "built-in" var Version = "built-in"
// Definition returns the CheckerDefinition for the TLS checker. // Definition returns the CheckerDefinition for the TLS checker.
func Definition() *sdk.CheckerDefinition { func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "tls", ID: "tls",
Name: "TLS", Name: "TLS",
@ -29,6 +29,13 @@ func Definition() *sdk.CheckerDefinition {
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.", Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
Default: float64(DefaultProbeTimeoutMs), 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{ RunOpts: []sdk.CheckerOptionDocumentation{
{ {
@ -40,9 +47,7 @@ func Definition() *sdk.CheckerDefinition {
}, },
}, },
}, },
Rules: []sdk.CheckRule{ Rules: Rules(),
Rule(),
},
Interval: &sdk.CheckIntervalSpec{ Interval: &sdk.CheckIntervalSpec{
Min: 6 * time.Hour, Min: 6 * time.Hour,
Max: 7 * 24 * time.Hour, Max: 7 * 24 * time.Hour,

68
checker/enumerate.go Normal file
View file

@ -0,0 +1,68 @@
package checker
import (
"context"
"net"
"strconv"
"strings"
"time"
"git.happydns.org/checker-tls/contract"
"git.happydns.org/checker-tls/tlsenum"
)
// enumerationProbeTimeout caps each individual sub-probe. It is intentionally
// shorter than the main probe timeout: a sweep does dozens of handshakes and
// most rejections come back in tens of ms, so 3s is enough to absorb a slow
// network without dragging the total cost.
const enumerationProbeTimeout = 3 * time.Second
// enumerateEndpoint runs a (version × cipher) sweep against an endpoint —
// direct TLS or STARTTLS — and returns the result in the wire-format consumed
// by rules. It returns (nil, "<reason>") 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

198
checker/enumerate_test.go Normal file
View file

@ -0,0 +1,198 @@
package checker
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"strconv"
"testing"
"time"
"git.happydns.org/checker-tls/contract"
)
// startEnumTestServer spins up a TCP listener that, for every accepted
// connection: (1) optionally drives a fake STARTTLS dialect handshake, then
// (2) lets the standard library terminate TLS with the provided cert. It
// keeps accepting until the test closes the listener.
//
// We use the stdlib tls.Server (not utls) on the server side: the point of
// these tests is to exercise the *checker* glue (upgraderFor + enumerate)
// against the real client-side code, not to replay tlsenum's internals.
func startEnumTestServer(t *testing.T, withSTARTTLS bool, cert tls.Certificate) net.Listener {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go handleEnumConn(c, withSTARTTLS, cert)
}
}()
return ln
}
func handleEnumConn(c net.Conn, withSTARTTLS bool, cert tls.Certificate) {
defer c.Close()
if withSTARTTLS {
// Pretend to be SMTP: 220 banner, EHLO ack, STARTTLS ack. The
// implementation of starttlsSMTP only requires the server to
// advertise STARTTLS in its EHLO response and to reply with a 2xx
// to the STARTTLS verb — exact verbs come from RFC 3207.
if _, err := io.WriteString(c, "220 enum.test ESMTP\r\n"); err != nil {
return
}
buf := make([]byte, 1024)
// EHLO line
if _, err := c.Read(buf); err != nil {
return
}
if _, err := io.WriteString(c, "250-enum.test\r\n250 STARTTLS\r\n"); err != nil {
return
}
// STARTTLS line
if _, err := c.Read(buf); err != nil {
return
}
if _, err := io.WriteString(c, "220 ready\r\n"); err != nil {
return
}
}
tc := tls.Server(c, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12, // narrow surface so the sweep is fast
})
defer tc.Close()
_ = tc.Handshake()
}
// enumTestCert is a one-time self-signed ECDSA cert reused across tests.
func enumTestCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "enum.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"enum.test"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("createcert: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
c, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
)
if err != nil {
t.Fatalf("keypair: %v", err)
}
return c
}
func portOf(t *testing.T, ln net.Listener) uint16 {
t.Helper()
_, p, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("split addr: %v", err)
}
n, err := strconv.ParseUint(p, 10, 16)
if err != nil {
t.Fatalf("parse port: %v", err)
}
return uint16(n)
}
// TestEnumerateEndpoint_DirectTLS asserts the sweep returns at least one
// supported version + cipher when the endpoint is plain TLS — proving the
// nil-upgrader path of upgraderFor wires correctly.
func TestEnumerateEndpoint_DirectTLS(t *testing.T) {
cert := enumTestCert(t)
ln := startEnumTestServer(t, false, cert)
defer ln.Close()
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: portOf(t, ln),
SNI: "enum.test",
}, 30*time.Second)
if skip != "" {
t.Fatalf("unexpected skip reason: %q", skip)
}
if res == nil || len(res.Versions) == 0 {
t.Fatalf("expected at least one supported version, got %+v", res)
}
gotTLS12 := false
for _, v := range res.Versions {
if v.Version == tls.VersionTLS12 && len(v.Ciphers) > 0 {
gotTLS12 = true
}
}
if !gotTLS12 {
t.Fatalf("expected TLS 1.2 with at least one cipher, got %+v", res.Versions)
}
}
// TestEnumerateEndpoint_SMTP_STARTTLS asserts the sweep drives the SMTP
// dialect upgrade on every sub-probe and still discovers ciphers — proving
// the upgraderFor("smtp", sni) path is wired into Enumerate.
func TestEnumerateEndpoint_SMTP_STARTTLS(t *testing.T) {
cert := enumTestCert(t)
ln := startEnumTestServer(t, true, cert)
defer ln.Close()
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: portOf(t, ln),
SNI: "enum.test",
STARTTLS: "smtp",
}, 60*time.Second)
if skip != "" {
t.Fatalf("unexpected skip reason: %q", skip)
}
if res == nil || len(res.Versions) == 0 {
t.Fatalf("expected at least one supported version through STARTTLS, got %+v", res)
}
}
// TestEnumerateEndpoint_UnknownDialect asserts an unsupported STARTTLS
// dialect is rejected with a non-empty skip reason and no result — the
// observation must record *why* enumeration didn't run, not silently report
// "no versions accepted".
func TestEnumerateEndpoint_UnknownDialect(t *testing.T) {
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: 1, // unreachable on purpose; we never get past the dialect check
STARTTLS: "no-such-dialect",
}, time.Second)
if res != nil {
t.Fatalf("expected nil result for unknown dialect, got %+v", res)
}
if skip == "" {
t.Fatalf("expected non-empty skip reason for unknown dialect")
}
}

View file

@ -68,7 +68,7 @@ func FetchChain(ctx context.Context, host string, port uint16, starttls string,
tlsConn := tls.Client(conn, &tls.Config{ tlsConn := tls.Client(conn, &tls.Config{
ServerName: host, ServerName: host,
InsecureSkipVerify: true, InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it
}) })
if err := tlsConn.HandshakeContext(dialCtx); err != nil { if err := tlsConn.HandshakeContext(dialCtx); err != nil {
return nil, fmt.Errorf("tls handshake: %w", err) return nil, fmt.Errorf("tls handshake: %w", err)

View file

@ -1,3 +1,5 @@
//go:build standalone
package checker package checker
import ( import (
@ -24,7 +26,7 @@ func starttlsChoices() []string {
return protos return protos
} }
// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs // RenderForm satisfies server.Interactive. The fields mirror the inputs
// a producer checker would put into a contract.TLSEndpoint; a human fills // a producer checker would put into a contract.TLSEndpoint; a human fills
// them in directly when running the checker standalone. // them in directly when running the checker standalone.
func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField { func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
@ -75,7 +77,7 @@ func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
} }
} }
// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into // ParseForm satisfies server.Interactive. It turns the human inputs into
// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns // a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns
// CheckerOptions shaped as if a happyDomain host had auto-filled // CheckerOptions shaped as if a happyDomain host had auto-filled
// OptionEndpoints via AutoFillDiscoveryEntries. // OptionEndpoints via AutoFillDiscoveryEntries.

View file

@ -58,8 +58,11 @@ func probeTypeString(ep contract.TLSEndpoint) string {
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the // probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
// given endpoint and returns a populated TLSProbe. It never returns an error: // given endpoint and returns a populated TLSProbe. It never returns an error:
// transport/handshake failures are recorded on the probe so the caller can // transport/handshake failures are recorded on the probe as raw fields so
// still surface them in the report. // rules can classify them.
//
// This function MUST NOT decide severity or pass/fail: it only gathers
// observation data. All judgement happens in CheckRules (see rules_*.go).
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe { func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
start := time.Now() start := time.Now()
host := strings.TrimSuffix(ep.Host, ".") host := strings.TrimSuffix(ep.Host, ".")
@ -75,6 +78,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
Endpoint: addr, Endpoint: addr,
Type: probeTypeString(ep), Type: probeTypeString(ep),
SNI: sni, SNI: sni,
RequireSTARTTLS: ep.RequireSTARTTLS,
STARTTLSDialect: ep.STARTTLS,
} }
dialCtx, cancel := context.WithTimeout(ctx, timeout) dialCtx, cancel := context.WithTimeout(ctx, timeout)
@ -83,13 +88,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
d := &net.Dialer{} d := &net.Dialer{}
conn, err := d.DialContext(dialCtx, "tcp", addr) conn, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil { if err != nil {
p.TCPError = err.Error()
p.Error = "dial: " + 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() p.ElapsedMS = time.Since(start).Milliseconds()
return p return p
} }
@ -101,23 +101,28 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
tlsConn, err := handshake(conn, ep, sni) tlsConn, err := handshake(conn, ep, sni)
if err != nil { if err != nil {
p.HandshakeError = err.Error()
p.Error = err.Error() p.Error = err.Error()
p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
p.STARTTLSNotOffered = true
}
if errors.Is(err, errUnsupportedStartTLSProto) {
p.STARTTLSUnsupportedProto = true
}
p.ElapsedMS = time.Since(start).Milliseconds() p.ElapsedMS = time.Since(start).Milliseconds()
return p return p
} }
defer tlsConn.Close() defer tlsConn.Close()
p.TLSHandshakeOK = true
state := tlsConn.ConnectionState() state := tlsConn.ConnectionState()
p.TLSVersionNum = state.Version
p.TLSVersion = tls.VersionName(state.Version) p.TLSVersion = tls.VersionName(state.Version)
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite) p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
p.CipherSuiteID = state.CipherSuite
if len(state.PeerCertificates) == 0 { if len(state.PeerCertificates) == 0 {
p.Issues = append(p.Issues, Issue{ p.NoPeerCert = true
Code: "no_peer_cert",
Severity: SeverityCrit,
Message: "Server presented no certificate.",
})
p.ElapsedMS = time.Since(start).Milliseconds() p.ElapsedMS = time.Since(start).Milliseconds()
return p return p
} }
@ -130,16 +135,16 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId)) p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId))
} }
p.Subject = leaf.Subject.CommonName p.Subject = leaf.Subject.CommonName
p.DNSNames = append(p.DNSNames, leaf.DNSNames...) p.DNSNames = leaf.DNSNames
p.Chain = buildChain(state.PeerCertificates) p.Chain = buildChain(state.PeerCertificates)
hostnameMatch := leaf.VerifyHostname(sni) == nil hostnameMatch := leaf.VerifyHostname(sni) == nil
p.HostnameMatch = &hostnameMatch p.HostnameMatch = &hostnameMatch
// Chain verification against system roots, using intermediates presented // Chain verification against system roots, using intermediates presented
// by the server. We run this independently from Go's tls.Config // by the server. Running it separately from tls.Config verification
// verification so we can report a dedicated "chain invalid" issue rather // means we can record it as a raw observation rather than aborting the
// than failing the whole handshake. // handshake, rules classify it afterwards.
intermediates := x509.NewCertPool() intermediates := x509.NewCertPool()
for _, c := range state.PeerCertificates[1:] { for _, c := range state.PeerCertificates[1:] {
intermediates.AddCert(c) intermediates.AddCert(c)
@ -152,48 +157,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
}) })
chainValid := verifyErr == nil chainValid := verifyErr == nil
p.ChainValid = &chainValid p.ChainValid = &chainValid
if !chainValid {
msg := "Invalid certificate chain"
if verifyErr != nil { if verifyErr != nil {
msg = "Invalid certificate chain: " + verifyErr.Error() p.ChainVerifyErr = 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() p.ElapsedMS = time.Since(start).Milliseconds()
@ -202,12 +167,12 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and // handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify // then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
// the chain separately in probe so an invalid chain becomes a structured // the chain separately in probe so an invalid chain becomes a raw
// Issue rather than aborting the handshake. // observation rather than aborting the handshake.
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
cfg := &tls.Config{ cfg := &tls.Config{
ServerName: sni, ServerName: sni,
InsecureSkipVerify: true, InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe()
} }
if ep.STARTTLS == "" { if ep.STARTTLS == "" {
@ -220,7 +185,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
up, ok := starttlsUpgraders[ep.STARTTLS] up, ok := starttlsUpgraders[ep.STARTTLS]
if !ok { if !ok {
return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS) return nil, fmt.Errorf("%w: %q", errUnsupportedStartTLSProto, ep.STARTTLS)
} }
if err := up(conn, sni); err != nil { if err := up(conn, sni); err != nil {
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err) return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
@ -232,34 +197,10 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
return tlsConn, nil return tlsConn, nil
} }
// classifyHandshakeError converts a dial/handshake error into a structured var (
// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic errStartTLSNotOffered = errors.New("starttls not advertised by server")
// for some endpoints) from hard failures. errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol")
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 { func isStartTLSUnsupported(err error) bool {
return errors.Is(err, errStartTLSNotOffered) return errors.Is(err, errStartTLSNotOffered)

View file

@ -60,11 +60,8 @@ func TestProbe_TCPUnreachable(t *testing.T) {
Port: uint16(addr.Port), Port: uint16(addr.Port),
}, 1*time.Second) }, 1*time.Second)
if probe.Error == "" { if probe.TCPError == "" {
t.Errorf("expected an error for unreachable port") t.Errorf("expected a TCP 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)
} }
} }

View file

@ -8,140 +8,83 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into // Rules returns the full list of CheckRules exposed by the TLS checker.
// a single status for this checker run. // Each rule covers a single concern (reachability, handshake, chain, hostname,
func Rule() sdk.CheckRule { // expiry, TLS version, STARTTLS advertisement, cipher suite, …) so the UI can
return &tlsRule{} // 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{},
}
} }
type tlsRule struct{} // loadData fetches the TLS observation. On error, returns a single error
// state the caller should emit.
func (r *tlsRule) Name() string { return "tls_posture" } func loadData(ctx context.Context, obs sdk.ObservationGetter) (*TLSData, *sdk.CheckState) {
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 var data TLSData
if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil { if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil {
return []sdk.CheckState{{ return nil, &sdk.CheckState{
Status: sdk.StatusError, Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read tls_probes: %v", err), Message: fmt.Sprintf("failed to load tls_probes observation: %v", err),
Code: "tls_observation_error", Code: "tls.observation_error",
}} }
} }
return &data, nil
// 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",
}}
} }
// 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)) refs := make([]string, 0, len(data.Probes))
for ref := range data.Probes { for ref := range data.Probes {
refs = append(refs, ref) refs = append(refs, ref)
} }
sort.Strings(refs) 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
} }
// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the // subjectOf formats the UI-facing subject for a single probe.
// probed endpoint so the host can correlate states across runs and surface func subjectOf(p TLSProbe) string {
// them per-target in the UI. Message describes the finding only -- the UI return fmt.Sprintf("%s://%s", p.Type, p.Endpoint)
// renders Subject separately. }
func evaluateProbe(p TLSProbe) sdk.CheckState {
subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) // metaOf returns a compact meta map to attach to a CheckState.
meta := map[string]any{ func metaOf(p TLSProbe) map[string]any {
m := map[string]any{
"type": p.Type, "type": p.Type,
"host": p.Host, "host": p.Host,
"port": p.Port, "port": p.Port,
"sni": p.SNI, "sni": p.SNI,
"issues": len(p.Issues),
} }
if p.TLSVersion != "" { if p.TLSVersion != "" {
meta["tls_version"] = p.TLSVersion m["tls_version"] = p.TLSVersion
} }
if !p.NotAfter.IsZero() { return m
meta["not_after"] = p.NotAfter
} }
worst, critMsg, warnMsg := summarize(p.Issues) // passState / infoState / unknownState helpers.
switch worst { func passState(code, message string) sdk.CheckState {
case SeverityCrit: return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: message}
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,
}
} }
func unknownState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: message}
} }
// summarize walks the issues once and returns (worst severity, first // emptyCaseState returns a single state describing "no probes to evaluate".
// critical message, first warning message). Picking the messages during the // Rules call this when len(data.Probes) == 0 to avoid returning an empty
// same pass avoids a second iteration in the caller. // slice (see CheckRule.Evaluate contract).
func summarize(issues []Issue) (worst, firstCrit, firstWarn string) { func emptyCaseState(code string) sdk.CheckState {
for _, is := range issues { return unknownState(code, "No TLS endpoints have been discovered for this target yet.")
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
} }

View file

@ -0,0 +1,233 @@
package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// peerCertificateRule flags successful handshakes in which the server sent
// no certificate. This is distinct from chain validity: if no cert was sent,
// hostname/chain/expiry cannot be evaluated.
type peerCertificateRule struct{}
func (r *peerCertificateRule) Name() string { return "tls.peer_certificate_present" }
func (r *peerCertificateRule) Description() string {
return "Verifies the server presented a certificate during the TLS handshake."
}
func (r *peerCertificateRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.peer_certificate_present.no_endpoints")}
}
var out []sdk.CheckState
anyHandshake := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if !p.TLSHandshakeOK {
continue
}
anyHandshake = true
if !p.NoPeerCert {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.peer_certificate_present.missing",
Subject: subjectOf(p),
Message: fmt.Sprintf("Server on %s completed the handshake but presented no certificate.", p.Endpoint),
Meta: metaOf(p),
})
}
if !anyHandshake {
return []sdk.CheckState{unknownState(
"tls.peer_certificate_present.skipped",
"No endpoint completed a TLS handshake.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.peer_certificate_present.ok",
"Every endpoint presented a certificate.",
)}
}
return out
}
// chainValidityRule flags invalid certificate chains.
type chainValidityRule struct{}
func (r *chainValidityRule) Name() string { return "tls.chain_validity" }
func (r *chainValidityRule) Description() string {
return "Verifies the presented certificate chain validates against the system trust store."
}
func (r *chainValidityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.chain_validity.no_endpoints")}
}
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.ChainValid == nil {
continue
}
any = true
if *p.ChainValid {
continue
}
msg := "Invalid certificate chain"
if p.ChainVerifyErr != "" {
msg = "Invalid certificate chain: " + p.ChainVerifyErr
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.chain_validity.invalid",
Subject: subjectOf(p),
Message: msg,
Meta: metaOf(p),
})
}
if !any {
return []sdk.CheckState{unknownState(
"tls.chain_validity.skipped",
"No endpoint yielded a certificate chain to verify.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.chain_validity.ok",
"Every presented chain validates against the system trust store.",
)}
}
return out
}
// hostnameMatchRule flags endpoints whose leaf cert does not cover the SNI
// the probe used.
type hostnameMatchRule struct{}
func (r *hostnameMatchRule) Name() string { return "tls.hostname_match" }
func (r *hostnameMatchRule) Description() string {
return "Verifies the leaf certificate covers the probed hostname (SNI)."
}
func (r *hostnameMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.hostname_match.no_endpoints")}
}
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.HostnameMatch == nil {
continue
}
any = true
if *p.HostnameMatch {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.hostname_match.mismatch",
Subject: subjectOf(p),
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", p.SNI, strings.Join(p.DNSNames, ", ")),
Meta: metaOf(p),
})
}
if !any {
return []sdk.CheckState{unknownState(
"tls.hostname_match.skipped",
"No endpoint yielded a certificate to hostname-match.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.hostname_match.ok",
"Every certificate covers its probed SNI.",
)}
}
return out
}
// expiryRule flags expired or near-expiry certificates.
type expiryRule struct{}
func (r *expiryRule) Name() string { return "tls.expiry" }
func (r *expiryRule) Description() string {
return "Flags expired or soon-to-expire leaf certificates."
}
func (r *expiryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.expiry.no_endpoints")}
}
now := time.Now()
var out []sdk.CheckState
any := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.NotAfter.IsZero() {
continue
}
any = true
meta := metaOf(p)
meta["not_after"] = p.NotAfter
if p.NotAfter.Before(now) {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.expiry.expired",
Subject: subjectOf(p),
Message: "Certificate expired on " + p.NotAfter.Format(time.RFC3339),
Meta: meta,
})
continue
}
if p.NotAfter.Sub(now) < ExpiringSoonThreshold {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "tls.expiry.expiring_soon",
Subject: subjectOf(p),
Message: "Certificate expires in less than 14 days (" + p.NotAfter.Format(time.RFC3339) + ")",
Meta: meta,
})
}
}
if !any {
return []sdk.CheckState{unknownState(
"tls.expiry.skipped",
"No endpoint yielded a certificate with an expiry to check.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.expiry.ok",
"Every leaf certificate is valid for more than 14 days.",
)}
}
return out
}

View file

@ -0,0 +1,34 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// endpointsDiscoveredRule surfaces the "no producer has published endpoints
// for this target yet" steady state. Kept as its own rule so it does not
// contaminate per-endpoint findings when discovery is in flight.
type endpointsDiscoveredRule struct{}
func (r *endpointsDiscoveredRule) Name() string { return "tls.endpoints_discovered" }
func (r *endpointsDiscoveredRule) Description() string {
return "Verifies that at least one TLS endpoint has been discovered for this target."
}
func (r *endpointsDiscoveredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{unknownState(
"tls.endpoints_discovered.none",
"No TLS endpoints have been discovered for this target yet.",
)}
}
return []sdk.CheckState{passState(
"tls.endpoints_discovered.ok",
"TLS endpoints were discovered for this target.",
)}
}

View file

@ -0,0 +1,197 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// hasEnum returns true when at least one probe carries enumeration data.
// Rules use this to short-circuit to "skipped" when the user hasn't enabled
// the enumerate option (rather than falsely emitting a "passing" verdict).
func hasEnum(data *TLSData) bool {
for _, p := range data.Probes {
if p.Enum != nil && len(p.Enum.Versions) > 0 {
return true
}
}
return false
}
// versionEnumerationRule reports the full set of protocol versions accepted
// by each endpoint, and flags any acceptance below the TLS 1.2 floor — the
// regular handshake rule only sees the *negotiated* version, so a server
// that still accepts TLS 1.0 alongside TLS 1.3 would otherwise look healthy.
type versionEnumerationRule struct{}
func (r *versionEnumerationRule) Name() string { return "tls.enum.versions" }
func (r *versionEnumerationRule) Description() string {
return "Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option)."
}
func (r *versionEnumerationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.enum.versions.no_endpoints")}
}
if !hasEnum(data) {
return []sdk.CheckState{unknownState(
"tls.enum.versions.skipped",
"TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).",
)}
}
var out []sdk.CheckState
anyEnum := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.Enum == nil || len(p.Enum.Versions) == 0 {
continue
}
anyEnum = true
var legacy []string
for _, v := range p.Enum.Versions {
if v.Version < tls.VersionTLS12 {
legacy = append(legacy, v.Name)
}
}
if len(legacy) == 0 {
continue
}
sort.Strings(legacy)
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "tls.enum.versions.legacy_accepted",
Subject: subjectOf(p),
Message: fmt.Sprintf("Endpoint accepts legacy protocol version(s): %s.", strings.Join(legacy, ", ")),
Meta: metaOf(p),
})
}
if !anyEnum {
return []sdk.CheckState{unknownState(
"tls.enum.versions.skipped",
"No endpoint produced enumeration data.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.enum.versions.ok",
"No endpoint accepts a protocol version below TLS 1.2.",
)}
}
return out
}
// weakCipherRule flags endpoints that accept cipher suites widely considered
// broken or insecure: NULL, anonymous, EXPORT, RC4, 3DES, and any other CBC
// suite using SHA-1 in MAC-then-encrypt mode is *not* flagged here because
// real-world servers still need them for legacy clients; this rule limits
// itself to the set with no defensible use in 2026.
type weakCipherRule struct{}
func (r *weakCipherRule) Name() string { return "tls.enum.ciphers" }
func (r *weakCipherRule) Description() string {
return "Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES)."
}
// classifyCipher returns a non-empty category when the named cipher belongs
// to a class with no defensible modern use. The check is by substring on the
// IANA name because every entry follows the TLS_<KX>_WITH_<CIPHER>_<MAC>
// 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
}

View file

@ -0,0 +1,135 @@
package checker
import (
"context"
"crypto/tls"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObs is a minimal ObservationGetter that serves a pre-built TLSData
// payload and ignores related lookups. It is local to this file rather than
// promoted to a shared helper to keep the rule tests self-contained.
type stubObs struct{ data TLSData }
func (s stubObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != ObservationKeyTLSProbes {
return nil
}
raw, _ := json.Marshal(s.data)
return json.Unmarshal(raw, dest)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func newProbeWithEnum(versions ...EnumVersion) TLSProbe {
return TLSProbe{
Host: "example.test", Port: 443, Endpoint: "example.test:443", Type: "tls",
TLSHandshakeOK: true, TLSVersionNum: tls.VersionTLS13,
Enum: &TLSEnumeration{Versions: versions},
}
}
func TestVersionEnumerationRule_Skipped_NoEnum(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{"a": {Host: "x", Port: 443, Endpoint: "x:443", Type: "tls", TLSHandshakeOK: true}},
CollectedAt: time.Now(),
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Code != "tls.enum.versions.skipped" {
t.Fatalf("want a single skipped state, got %+v", got)
}
}
func TestVersionEnumerationRule_OK_OnlyModern(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"},
EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3"},
),
},
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.versions.ok" {
t.Fatalf("want a single OK state, got %+v", got)
}
}
func TestVersionEnumerationRule_LegacyAccepted(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS10, Name: "TLS 1.0"},
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"},
),
},
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.versions.legacy_accepted" {
t.Fatalf("want a single warn state, got %+v", got)
}
if !strings.Contains(got[0].Message, "TLS 1.0") {
t.Fatalf("warn message should mention the legacy version, got %q", got[0].Message)
}
}
func TestClassifyCipher(t *testing.T) {
cases := map[string]string{
"TLS_RSA_WITH_NULL_SHA": "NULL",
"TLS_DH_anon_WITH_AES_128_CBC_SHA": "anonymous",
"TLS_RSA_EXPORT_WITH_RC4_40_MD5": "EXPORT",
"TLS_ECDHE_RSA_WITH_RC4_128_SHA": "RC4",
"TLS_RSA_WITH_3DES_EDE_CBC_SHA": "3DES/DES",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "",
"TLS_AES_256_GCM_SHA384": "",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "",
}
for name, want := range cases {
if got := classifyCipher(name); got != want {
t.Errorf("classifyCipher(%q) = %q, want %q", name, got, want)
}
}
}
func TestWeakCipherRule_Detects(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2", Ciphers: []EnumCipher{
{ID: 0xC02F, Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"},
{ID: 0x000A, Name: "TLS_RSA_WITH_3DES_EDE_CBC_SHA"},
{ID: 0x0005, Name: "TLS_RSA_WITH_RC4_128_SHA"},
}},
),
},
}}
got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.ciphers.weak_accepted" {
t.Fatalf("want a single weak warn state, got %+v", got)
}
if !strings.Contains(got[0].Message, "RC4") || !strings.Contains(got[0].Message, "3DES/DES") {
t.Fatalf("warn message should list the broken categories, got %q", got[0].Message)
}
}
func TestWeakCipherRule_OK_OnlyModern(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3", Ciphers: []EnumCipher{
{ID: 0x1301, Name: "TLS_AES_128_GCM_SHA256"},
}},
),
},
}}
got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.ciphers.ok" {
t.Fatalf("want a single OK state, got %+v", got)
}
}

View file

@ -0,0 +1,60 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsHandshakeRule flags reachable endpoints on which the TLS handshake
// failed. STARTTLS-specific shortfalls (server not advertising the upgrade)
// are surfaced by starttlsAdvertisedRule / starttlsSupportedRule instead,
// so this rule skips them.
type tlsHandshakeRule struct{}
func (r *tlsHandshakeRule) Name() string { return "tls.handshake" }
func (r *tlsHandshakeRule) Description() string {
return "Verifies the TLS handshake completes on every reachable endpoint."
}
func (r *tlsHandshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.handshake.no_endpoints")}
}
var out []sdk.CheckState
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.TCPError != "" {
continue // reachability covers this.
}
if p.STARTTLSNotOffered || p.STARTTLSUnsupportedProto {
continue // starttls-specific rules cover these.
}
if p.TLSHandshakeOK {
continue
}
if p.HandshakeError == "" {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.handshake.failed",
Subject: subjectOf(p),
Message: fmt.Sprintf("TLS handshake failed on %s: %s", p.Endpoint, p.HandshakeError),
Meta: metaOf(p),
})
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.handshake.ok",
"TLS handshake succeeded on every reachable endpoint.",
)}
}
return out
}

120
checker/rules_protocol.go Normal file
View file

@ -0,0 +1,120 @@
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},
}}
}

View file

@ -0,0 +1,48 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// reachabilityRule flags endpoints that did not accept a TCP connection.
type reachabilityRule struct{}
func (r *reachabilityRule) Name() string { return "tls.reachability" }
func (r *reachabilityRule) Description() string {
return "Verifies that every discovered TLS endpoint accepts a TCP connection."
}
func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.reachability.no_endpoints")}
}
var out []sdk.CheckState
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.TCPError == "" {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "tls.reachability.tcp_unreachable",
Subject: subjectOf(p),
Message: fmt.Sprintf("Cannot open TCP connection to %s: %s", p.Endpoint, p.TCPError),
Meta: metaOf(p),
})
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.reachability.ok",
"All discovered endpoints accepted a TCP connection.",
)}
}
return out
}

108
checker/rules_starttls.go Normal file
View file

@ -0,0 +1,108 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// starttlsAdvertisedRule flags STARTTLS endpoints whose server did not
// advertise the upgrade. Severity depends on RequireSTARTTLS: opportunistic
// STARTTLS degrades to a warning; mandatory STARTTLS is critical.
type starttlsAdvertisedRule struct{}
func (r *starttlsAdvertisedRule) Name() string { return "tls.starttls_advertised" }
func (r *starttlsAdvertisedRule) Description() string {
return "Verifies that STARTTLS endpoints advertise the upgrade capability."
}
func (r *starttlsAdvertisedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.starttls_advertised.no_endpoints")}
}
var out []sdk.CheckState
anySTARTTLS := false
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if p.STARTTLSDialect == "" {
continue
}
anySTARTTLS = true
if !p.STARTTLSNotOffered {
continue
}
status := sdk.StatusWarn
if p.RequireSTARTTLS {
status = sdk.StatusCrit
}
out = append(out, sdk.CheckState{
Status: status,
Code: "tls.starttls_advertised.missing",
Subject: subjectOf(p),
Message: fmt.Sprintf("Server on %s does not advertise STARTTLS.", p.Endpoint),
Meta: metaOf(p),
})
}
if !anySTARTTLS {
return []sdk.CheckState{unknownState(
"tls.starttls_advertised.not_applicable",
"No STARTTLS endpoint in the discovered set.",
)}
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.starttls_advertised.ok",
"STARTTLS is advertised on every STARTTLS endpoint.",
)}
}
return out
}
// starttlsSupportedRule flags endpoints whose STARTTLS dialect is not
// implemented by this checker. A misconfigured discovery entry (typo, new
// protocol) should be visible as its own concern rather than blending into
// generic handshake failures.
type starttlsSupportedRule struct{}
func (r *starttlsSupportedRule) Name() string { return "tls.starttls_dialect_supported" }
func (r *starttlsSupportedRule) Description() string {
return "Verifies that discovered STARTTLS dialects are implemented by the checker."
}
func (r *starttlsSupportedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Probes) == 0 {
return []sdk.CheckState{emptyCaseState("tls.starttls_dialect_supported.no_endpoints")}
}
var out []sdk.CheckState
for _, ref := range sortedRefs(data) {
p := data.Probes[ref]
if !p.STARTTLSUnsupportedProto {
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusError,
Code: "tls.starttls_dialect_supported.unknown",
Subject: subjectOf(p),
Message: fmt.Sprintf("Unsupported STARTTLS dialect %q for %s.", p.STARTTLSDialect, p.Endpoint),
Meta: metaOf(p),
})
}
if len(out) == 0 {
return []sdk.CheckState{passState(
"tls.starttls_dialect_supported.ok",
"Every STARTTLS dialect encountered is implemented.",
)}
}
return out
}

View file

@ -1,6 +1,41 @@
package checker package checker
import "net" import (
"bufio"
"fmt"
"io"
"net"
)
// maxSTARTTLSLineBytes caps the length of a single line read from a STARTTLS
// peer. Real banners and CAPABILITY responses are well under 1 KiB; this
// bound prevents a malicious or buggy server from exhausting memory by
// withholding the line terminator.
const maxSTARTTLSLineBytes = 8 * 1024
// readLineLimited reads bytes from r up to and including the next '\n', or
// until maxSTARTTLSLineBytes have been read without one (in which case it
// returns an error). The returned string keeps the trailing '\n' so callers
// can use the same parsing logic as bufio.Reader.ReadString('\n').
func readLineLimited(r *bufio.Reader) (string, error) {
out := make([]byte, 0, 128)
for {
b, err := r.ReadByte()
if err != nil {
if err == io.EOF && len(out) > 0 {
return string(out), io.ErrUnexpectedEOF
}
return string(out), err
}
out = append(out, b)
if b == '\n' {
return string(out), nil
}
if len(out) >= maxSTARTTLSLineBytes {
return string(out), fmt.Errorf("line exceeds %d bytes without terminator", maxSTARTTLSLineBytes)
}
}
}
// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on // starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on
// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success // conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success
@ -13,3 +48,18 @@ var starttlsUpgraders = map[string]starttlsUpgrader{}
func registerStartTLS(protocol string, upgrader starttlsUpgrader) { func registerStartTLS(protocol string, upgrader starttlsUpgrader) {
starttlsUpgraders[protocol] = upgrader 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
}

View file

@ -15,7 +15,7 @@ func init() {
func starttlsIMAP(conn net.Conn, sni string) error { func starttlsIMAP(conn net.Conn, sni string) error {
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
if _, err := rw.ReadString('\n'); err != nil { if _, err := readLineLimited(rw.Reader); err != nil {
return fmt.Errorf("read greeting: %w", err) 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) return fmt.Errorf("write CAPABILITY: %w", err)
} }
if err := rw.Flush(); err != nil { if err := rw.Flush(); err != nil {
return err return fmt.Errorf("flush CAPABILITY: %w", err)
} }
supportsSTARTTLS := false supportsSTARTTLS := false
for { for {
line, err := rw.ReadString('\n') line, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read CAPABILITY: %w", err) return fmt.Errorf("read CAPABILITY: %w", err)
} }
@ -36,6 +36,10 @@ func starttlsIMAP(conn net.Conn, sni string) error {
supportsSTARTTLS = true supportsSTARTTLS = true
} }
if strings.HasPrefix(line, "A001 ") { 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 break
} }
} }
@ -44,13 +48,13 @@ func starttlsIMAP(conn net.Conn, sni string) error {
} }
if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil { if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil {
return err return fmt.Errorf("write STARTTLS: %w", err)
} }
if err := rw.Flush(); err != nil { if err := rw.Flush(); err != nil {
return err return fmt.Errorf("flush STARTTLS: %w", err)
} }
for { for {
line, err := rw.ReadString('\n') line, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read STARTTLS response: %w", err) return fmt.Errorf("read STARTTLS response: %w", err)
} }

View file

@ -52,7 +52,10 @@ func starttlsLDAP(conn net.Conn, sni string) error {
if err != nil { if err != nil {
return fmt.Errorf("read response length: %w", err) return fmt.Errorf("read response length: %w", err)
} }
if length <= 0 || length > 4096 { // 16 KiB comfortably accommodates an ExtendedResponse with a verbose
// diagnosticMessage while still bounding memory against a hostile peer.
const maxLDAPResponseBytes = 16 * 1024
if length <= 0 || length > maxLDAPResponseBytes {
return fmt.Errorf("unreasonable LDAP response length %d", length) return fmt.Errorf("unreasonable LDAP response length %d", length)
} }
body := make([]byte, length) body := make([]byte, length)

View file

@ -15,7 +15,7 @@ func init() {
func starttlsPOP3(conn net.Conn, sni string) error { func starttlsPOP3(conn net.Conn, sni string) error {
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
greeting, err := rw.ReadString('\n') greeting, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read greeting: %w", err) 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 { if _, err := rw.WriteString("CAPA\r\n"); err != nil {
return err return fmt.Errorf("write CAPA: %w", err)
} }
if err := rw.Flush(); err != nil { if err := rw.Flush(); err != nil {
return err return fmt.Errorf("flush CAPA: %w", err)
} }
first, err := rw.ReadString('\n') first, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read CAPA: %w", err) return fmt.Errorf("read CAPA: %w", err)
} }
supportsSTLS := false supportsSTLS := false
if strings.HasPrefix(first, "+OK") { if strings.HasPrefix(first, "+OK") {
for { for {
line, err := rw.ReadString('\n') line, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read CAPA body: %w", err) 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 { if _, err := rw.WriteString("STLS\r\n"); err != nil {
return err return fmt.Errorf("write STLS: %w", err)
} }
if err := rw.Flush(); err != nil { if err := rw.Flush(); err != nil {
return err return fmt.Errorf("flush STLS: %w", err)
} }
resp, err := rw.ReadString('\n') resp, err := readLineLimited(rw.Reader)
if err != nil { if err != nil {
return fmt.Errorf("read STLS response: %w", err) return fmt.Errorf("read STLS response: %w", err)
} }

View file

@ -7,6 +7,11 @@ import (
"strings" "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() { func init() {
registerStartTLS("smtp", starttlsSMTP) registerStartTLS("smtp", starttlsSMTP)
registerStartTLS("submission", starttlsSMTP) registerStartTLS("submission", starttlsSMTP)
@ -20,7 +25,7 @@ func starttlsSMTP(conn net.Conn, sni string) error {
return fmt.Errorf("read greeting: %w", err) return fmt.Errorf("read greeting: %w", err)
} }
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil { if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil {
return fmt.Errorf("write ehlo: %w", err) return fmt.Errorf("write ehlo: %w", err)
} }
if err := rw.Flush(); err != nil { if err := rw.Flush(); err != nil {
@ -60,7 +65,7 @@ func readSMTPGreeting(r *bufio.Reader) error {
func readSMTPResponse(r *bufio.Reader) ([]string, error) { func readSMTPResponse(r *bufio.Reader) ([]string, error) {
var out []string var out []string
for { for {
line, err := r.ReadString('\n') line, err := readLineLimited(r)
if err != nil { if err != nil {
return out, err return out, err
} }

431
checker/starttls_test.go Normal file
View file

@ -0,0 +1,431 @@
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,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:features><starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'><required/></starttls></stream:features>`)
// Read the <starttls/> request from the client.
if _, err := br.Read(buf); err != nil {
return err
}
_, err := io.WriteString(c, `<proceed xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`)
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,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:features><mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'><mechanism>PLAIN</mechanism></mechanisms></stream:features>`)
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,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:features><starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/></stream:features>`)
if _, err := br.Read(buf); err != nil {
return err
}
_, err := io.WriteString(c, `<failure xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`)
return err
})
if err == nil {
t.Fatal("expected failure error")
}
if errors.Is(err, errStartTLSNotOffered) {
t.Fatalf("<failure/> 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,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:error><host-unknown xmlns='urn:ietf:params:xml:ns:xmpp-streams'/></stream:error>`)
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)
}
}

View file

@ -22,24 +22,41 @@ func starttlsXMPPServer(conn net.Conn, sni string) error {
return starttlsXMPP(conn, sni, "jabber:server") 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
// (<stream:stream> opening + <stream:features> + <proceed/>) 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 { func starttlsXMPP(conn net.Conn, sni, ns string) error {
header := fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, ns, sni) header := fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, ns, sni)
if _, err := io.WriteString(conn, header); err != nil { if _, err := io.WriteString(conn, header); err != nil {
return fmt.Errorf("write stream header: %w", err) return fmt.Errorf("write stream header: %w", err)
} }
dec := xml.NewDecoder(conn) dec := xml.NewDecoder(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit})
// Read the inbound <stream:stream> opening and its <stream:features>. // Read the inbound <stream:stream> opening and its <stream:features>.
// A peer that opens with <stream:error/> (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 hasStartTLS := false
outer:
for { for {
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return fmt.Errorf("read stream features: %w", err) return fmt.Errorf("read stream features: %w", err)
} }
if se, ok := tok.(xml.StartElement); ok { se, ok := tok.(xml.StartElement)
if se.Name.Local == "features" { if !ok {
// Scan features children. continue
}
switch se.Name.Local {
case "stream":
// Outer <stream:stream> opening. Continue reading children.
continue
case "features":
for { for {
t2, err := dec.Token() t2, err := dec.Token()
if err != nil { if err != nil {
@ -50,17 +67,21 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error {
if ee.Name.Local == "starttls" { if ee.Name.Local == "starttls" {
hasStartTLS = true hasStartTLS = true
} }
_ = dec.Skip() if err := dec.Skip(); err != nil {
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
}
case xml.EndElement: case xml.EndElement:
if ee.Name.Local == "features" { if ee.Name.Local == "features" {
goto doneFeatures break outer
} }
} }
} }
case "error":
return fmt.Errorf("server returned <stream:error/> before features")
default:
return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local)
} }
} }
}
doneFeatures:
if !hasStartTLS { if !hasStartTLS {
return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered) return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered)
} }

View file

@ -11,6 +11,7 @@ const ObservationKeyTLSProbes = "tls_probes"
const ( const (
OptionEndpoints = "endpoints" OptionEndpoints = "endpoints"
OptionProbeTimeoutMs = "probeTimeoutMs" OptionProbeTimeoutMs = "probeTimeoutMs"
OptionEnumerateCiphers = "enumerateCiphers"
) )
// Defaults shared between the definition's Default field and the runtime // Defaults shared between the definition's Default field and the runtime
@ -22,31 +23,65 @@ const (
MaxConcurrentProbes = 32 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. // TLSData is the full collected payload written under ObservationKeyTLSProbes.
type TLSData struct { type TLSData struct {
Probes map[string]TLSProbe `json:"probes"` Probes map[string]TLSProbe `json:"probes"`
CollectedAt time.Time `json:"collected_at"` CollectedAt time.Time `json:"collected_at"`
} }
// TLSProbe captures the outcome of probing a single endpoint. Field names // TLSProbe captures the outcome of probing a single endpoint.
// mirror what consumers already parse (checker-xmpp's tlsProbeView). //
// Only raw observation fields live here. Judgement (severity, pass/fail,
// human-facing messages) is derived from these fields by CheckRules.
type TLSProbe struct { type TLSProbe struct {
Host string `json:"host"` Host string `json:"host"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
Type string `json:"type"` Type string `json:"type"`
SNI string `json:"sni,omitempty"` 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"` TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,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"` HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"` ChainValid *bool `json:"chain_valid,omitempty"`
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"` NotAfter time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"` Issuer string `json:"issuer,omitempty"`
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
@ -65,8 +100,17 @@ type TLSProbe struct {
// DANE consumers can match without re-handshaking or re-parsing. // DANE consumers can match without re-handshaking or re-parsing.
Chain []CertInfo `json:"chain,omitempty"` Chain []CertInfo `json:"chain,omitempty"`
ElapsedMS int64 `json:"elapsed_ms,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"` Error string `json:"error,omitempty"`
Issues []Issue `json:"issues,omitempty"`
} }
// CertInfo describes one certificate in the presented chain together with // CertInfo describes one certificate in the presented chain together with
@ -101,10 +145,35 @@ type CertInfo struct {
SPKIDERBase64 string `json:"spki_der_base64,omitempty"` SPKIDERBase64 string `json:"spki_der_base64,omitempty"`
} }
// Issue is a single TLS finding surfaced to the consumer. // Expiry thresholds shared by rules.
type Issue struct { const (
Code string `json:"code"` ExpiringSoonThreshold = 14 * 24 * time.Hour
Severity string `json:"severity"` )
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"` // 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"`
} }

View file

@ -0,0 +1,91 @@
package checker
import (
"errors"
"net"
"testing"
)
// TestUpgraderFor_DirectTLS verifies that an empty dialect returns a nil
// upgrader with ok=true: tlsenum's contract is that nil means "no upgrade
// phase", so direct-TLS endpoints must round-trip through this branch
// without producing a shim that would call into the registry.
func TestUpgraderFor_DirectTLS(t *testing.T) {
up, ok := upgraderFor("", "example.test")
if !ok {
t.Fatalf("expected ok=true for empty dialect")
}
if up != nil {
t.Fatalf("expected nil upgrader for empty dialect, got %T", up)
}
}
func TestUpgraderFor_UnknownDialect(t *testing.T) {
up, ok := upgraderFor("totally-not-a-dialect", "example.test")
if ok {
t.Fatalf("expected ok=false for unknown dialect")
}
if up != nil {
t.Fatalf("expected nil upgrader for unknown dialect, got %T", up)
}
}
// TestUpgraderFor_KnownDialect_ForwardsSNI registers a temporary fake dialect
// in the registry, asks upgraderFor for its callback, invokes the callback,
// and asserts the registered upgrader received the expected SNI. We can't
// reuse a real dialect for this because they all read/write protocol-specific
// banners on the connection — the point of this test is the SNI plumbing in
// the closure, not the dialect's own behavior.
func TestUpgraderFor_KnownDialect_ForwardsSNI(t *testing.T) {
const dialect = "test-fake"
const wantSNI = "host.example.test"
var (
gotSNI string
gotConn net.Conn
)
wantErr := errors.New("sentinel from fake upgrader")
registerStartTLS(dialect, func(c net.Conn, sni string) error {
gotConn = c
gotSNI = sni
return wantErr
})
defer delete(starttlsUpgraders, dialect)
up, ok := upgraderFor(dialect, wantSNI)
if !ok || up == nil {
t.Fatalf("expected non-nil upgrader and ok=true, got nil=%v ok=%v", up == nil, ok)
}
// Use a closed pipe end as a sentinel net.Conn — the registered upgrader
// captures it without doing I/O, so a real connection is unnecessary.
a, b := net.Pipe()
_ = a.Close()
_ = b.Close()
if err := up(a); !errors.Is(err, wantErr) {
t.Fatalf("expected sentinel error to propagate, got %v", err)
}
if gotSNI != wantSNI {
t.Fatalf("registered upgrader received SNI %q, want %q", gotSNI, wantSNI)
}
if gotConn != a {
t.Fatalf("registered upgrader received a different conn than the one passed in")
}
}
// TestUpgraderFor_RealDialects_AllRegistered guards against silently dropping
// a dialect from the registry: every protocol referenced by the contract's
// STARTTLS values must resolve to a non-nil upgrader. The list mirrors the
// dialects implemented in starttls_*.go.
func TestUpgraderFor_RealDialects_AllRegistered(t *testing.T) {
dialects := []string{"smtp", "submission", "imap", "pop3", "xmpp-client", "xmpp-server", "ldap"}
for _, d := range dialects {
t.Run(d, func(t *testing.T) {
up, ok := upgraderFor(d, "host.example")
if !ok || up == nil {
t.Fatalf("dialect %q is not registered", d)
}
})
}
}

View file

@ -16,6 +16,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
@ -58,10 +59,27 @@ type TLSEndpoint struct {
RequireSTARTTLS bool `json:"require,omitempty"` 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 // NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref
// derived from ep, and a marshaled Payload. The returned entry can be // derived from ep, and a marshaled Payload. The returned entry can be
// returned as-is from a DiscoveryPublisher implementation. // returned as-is from a DiscoveryPublisher implementation.
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
if err := ep.Validate(); err != nil {
return sdk.DiscoveryEntry{}, err
}
payload, err := json.Marshal(ep) payload, err := json.Marshal(ep)
if err != nil { if err != nil {
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
@ -95,7 +113,7 @@ func Ref(ep TLSEndpoint) string {
req = "1" req = "1"
} }
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
sum := sha1.Sum([]byte(canonical)) sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
} }
@ -109,6 +127,9 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) {
if err := json.Unmarshal(e.Payload, &ep); err != nil { if err := json.Unmarshal(e.Payload, &ep); err != nil {
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
} }
if err := ep.Validate(); err != nil {
return TLSEndpoint{}, err
}
return ep, nil return ep, nil
} }
@ -123,7 +144,7 @@ type Entry struct {
} }
// ParseEntries filters entries to those of Type and decodes each payload. // 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 // contracts. Entries of this type whose Payload fails to unmarshal are
// skipped and returned as warnings so a single malformed payload cannot // skipped and returned as warnings so a single malformed payload cannot
// starve the checker of the rest of its workload. // starve the checker of the rest of its workload.

10
go.mod
View file

@ -2,4 +2,12 @@ module git.happydns.org/checker-tls
go 1.25.0 go 1.25.0
require git.happydns.org/checker-sdk-go v1.2.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
)

14
go.sum
View file

@ -1,2 +1,12 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= 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=

14
main.go
View file

@ -4,20 +4,28 @@ import (
"flag" "flag"
"log" "log"
sdk "git.happydns.org/checker-sdk-go/checker" "git.happydns.org/checker-sdk-go/checker/server"
tls "git.happydns.org/checker-tls/checker" tls "git.happydns.org/checker-tls/checker"
) )
var Version = "custom-build" 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") var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() { func main() {
flag.Parse() flag.Parse()
tls.Version = Version tls.Version = Version
if EHLOHostname != "" {
tls.EHLOHostname = EHLOHostname
}
server := sdk.NewServer(tls.Provider()) srv := server.New(tls.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil { if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err) log.Fatalf("server error: %v", err)
} }
} }

View file

@ -9,5 +9,6 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
tls.Version = Version tls.Version = Version
return tls.Definition(), tls.Provider(), nil prvd := tls.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }

103
tlsenum/ciphers.go Normal file
View file

@ -0,0 +1,103 @@
package tlsenum
// CipherSuite pairs an IANA TLS cipher suite ID with its standard name.
//
// The catalog below intentionally covers the "real-world" set: modern AEAD
// suites used by TLS 1.2/1.3, plus a long tail of legacy CBC/RC4/3DES/EXPORT
// suites we want to *detect* on remote servers (so we can flag them), even
// though Go's stdlib refuses to negotiate them. utls lets us put any 16-bit
// value in the offered list, so the server's accept/reject decision is the
// source of truth.
type CipherSuite struct {
ID uint16
Name string
// TLS13 is true for the five TLS 1.3 AEAD suites; those must only be
// offered with TLS 1.3 ClientHellos.
TLS13 bool
}
// TLS13Ciphers are the AEAD suites defined for TLS 1.3 (RFC 8446 §B.4).
var TLS13Ciphers = []CipherSuite{
{0x1301, "TLS_AES_128_GCM_SHA256", true},
{0x1302, "TLS_AES_256_GCM_SHA384", true},
{0x1303, "TLS_CHACHA20_POLY1305_SHA256", true},
{0x1304, "TLS_AES_128_CCM_SHA256", true},
{0x1305, "TLS_AES_128_CCM_8_SHA256", true},
}
// LegacyCiphers covers TLS 1.0/1.1/1.2 (and SSLv3) suites. Not exhaustive of
// the IANA registry, but it includes everything any modern audit cares about:
// ECDHE/DHE/RSA/PSK kex, AES-GCM/CCM/CBC, ChaCha20, 3DES, RC4, NULL, EXPORT,
// anonymous, and a handful of GOST/CAMELLIA/ARIA entries seen in the wild.
var LegacyCiphers = []CipherSuite{
// ECDHE-ECDSA
{0xC02B, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", false},
{0xC02C, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", false},
{0xCCA9, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", false},
{0xC023, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", false},
{0xC024, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", false},
{0xC009, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", false},
{0xC00A, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", false},
{0xC008, "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", false},
{0xC007, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", false},
{0xC006, "TLS_ECDHE_ECDSA_WITH_NULL_SHA", false},
// ECDHE-RSA
{0xC02F, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", false},
{0xC030, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", false},
{0xCCA8, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false},
{0xC027, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", false},
{0xC028, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", false},
{0xC013, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", false},
{0xC014, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", false},
{0xC012, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", false},
{0xC011, "TLS_ECDHE_RSA_WITH_RC4_128_SHA", false},
{0xC010, "TLS_ECDHE_RSA_WITH_NULL_SHA", false},
// DHE-RSA
{0x009E, "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", false},
{0x009F, "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", false},
{0xCCAA, "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", false},
{0x0067, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", false},
{0x006B, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", false},
{0x0033, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", false},
{0x0039, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", false},
{0x0016, "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", false},
// Plain RSA
{0x009C, "TLS_RSA_WITH_AES_128_GCM_SHA256", false},
{0x009D, "TLS_RSA_WITH_AES_256_GCM_SHA384", false},
{0x003C, "TLS_RSA_WITH_AES_128_CBC_SHA256", false},
{0x003D, "TLS_RSA_WITH_AES_256_CBC_SHA256", false},
{0x002F, "TLS_RSA_WITH_AES_128_CBC_SHA", false},
{0x0035, "TLS_RSA_WITH_AES_256_CBC_SHA", false},
{0x000A, "TLS_RSA_WITH_3DES_EDE_CBC_SHA", false},
{0x0005, "TLS_RSA_WITH_RC4_128_SHA", false},
{0x0004, "TLS_RSA_WITH_RC4_128_MD5", false},
{0x003B, "TLS_RSA_WITH_NULL_SHA256", false},
{0x0002, "TLS_RSA_WITH_NULL_SHA", false},
{0x0001, "TLS_RSA_WITH_NULL_MD5", false},
// Anonymous (broken by design — flag if seen)
{0x006D, "TLS_DH_anon_WITH_AES_256_CBC_SHA256", false},
{0x0034, "TLS_DH_anon_WITH_AES_128_CBC_SHA", false},
{0x003A, "TLS_DH_anon_WITH_AES_256_CBC_SHA", false},
{0xC018, "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", false},
{0xC019, "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", false},
// EXPORT (40-bit, illegal since ~2000 — flag if seen)
{0x0008, "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", false},
{0x0014, "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", false},
{0x0017, "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", false},
{0x0019, "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", false},
{0x0003, "TLS_RSA_EXPORT_WITH_RC4_40_MD5", false},
{0x0006, "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", false},
}
// AllCiphers concatenates legacy and TLS 1.3 cipher suites.
func AllCiphers() []CipherSuite {
out := make([]CipherSuite, 0, len(LegacyCiphers)+len(TLS13Ciphers))
out = append(out, LegacyCiphers...)
out = append(out, TLS13Ciphers...)
return out
}

283
tlsenum/tlsenum.go Normal file
View file

@ -0,0 +1,283 @@
// Package tlsenum probes a remote endpoint to discover the exact set of
// SSL/TLS protocol versions and cipher suites it accepts.
//
// The Go stdlib's crypto/tls only negotiates a curated subset of modern
// suites and refuses to even offer legacy ones (RC4, 3DES, EXPORT, NULL,
// anonymous, …), so it cannot be used to *audit* what a server accepts.
// Instead we use github.com/refraction-networking/utls to craft a fully
// custom ClientHello carrying a single (version, cipher) pair and let the
// server tell us — by ServerHello or alert — whether it accepts it.
//
// Scope of the minimal version:
// - TLS 1.0, 1.1, 1.2, 1.3 (negotiated via the SupportedVersions extension).
// - Direct TLS only; STARTTLS upgrade is the caller's responsibility for
// now (the existing checker package owns those dialect handlers).
// - SSLv3 and SSLv2 are deliberately out of scope; SSLv2 has a different
// wire format and would require either raw byte crafting or a legacy
// OpenSSL sidecar.
package tlsenum
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"time"
utls "github.com/refraction-networking/utls"
)
// AllVersions is the set of protocol versions Probe knows how to offer.
var AllVersions = []uint16{
utls.VersionTLS10,
utls.VersionTLS11,
utls.VersionTLS12,
utls.VersionTLS13,
}
// VersionName returns a human-readable label for a TLS protocol version.
func VersionName(v uint16) string {
switch v {
case utls.VersionTLS10:
return "TLS 1.0"
case utls.VersionTLS11:
return "TLS 1.1"
case utls.VersionTLS12:
return "TLS 1.2"
case utls.VersionTLS13:
return "TLS 1.3"
default:
return "0x" + strconv.FormatUint(uint64(v), 16)
}
}
// ProbeResult is the outcome of a single (version, cipher) attempt.
type ProbeResult struct {
OfferedVersion uint16
OfferedCipher uint16
// Accepted is true when the server completed enough of the handshake to
// echo back a ServerHello with our offered version and cipher. We do not
// require a fully successful handshake (certificate verification can fail
// for unrelated reasons); ServerHello acceptance is what we measure.
Accepted bool
// NegotiatedVersion / NegotiatedCipher are populated when Accepted is
// true. They should match the offered values; if they differ, the server
// is misbehaving (or downgrading).
NegotiatedVersion uint16
NegotiatedCipher uint16
// Err is the underlying error from the dial or handshake. For a clean
// "server rejected this combination" outcome it will typically be a TLS
// alert (handshake_failure, protocol_version, insufficient_security…).
Err error
}
// ProbeOptions controls a single Probe call.
type ProbeOptions struct {
// Timeout bounds dial + (optional) upgrade + handshake. A zero value
// means no deadline beyond the parent context's.
Timeout time.Duration
// Upgrader, when non-nil, is invoked on the freshly-dialed connection
// before the TLS ClientHello is sent. It is the injection point for
// STARTTLS dialect handlers (SMTP, IMAP, POP3, …): the callback drives
// the plaintext exchange that requests the upgrade and returns nil once
// the connection is ready for tls.Client. tlsenum stays agnostic of the
// dialect; the caller owns that knowledge.
Upgrader func(net.Conn) error
}
// Probe attempts a TLS handshake against addr offering exactly one protocol
// version and one cipher suite. It never panics; transport / handshake errors
// are reported on the returned ProbeResult.
//
// addr must be host:port. sni is the SNI to send (pass the host if unsure).
func Probe(ctx context.Context, addr, sni string, version, cipher uint16, opts ProbeOptions) ProbeResult {
res := ProbeResult{OfferedVersion: version, OfferedCipher: cipher}
dialCtx := ctx
if opts.Timeout > 0 {
var cancel context.CancelFunc
dialCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
d := &net.Dialer{}
raw, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil {
res.Err = fmt.Errorf("dial: %w", err)
return res
}
defer raw.Close()
if dl, ok := dialCtx.Deadline(); ok {
_ = raw.SetDeadline(dl)
}
if opts.Upgrader != nil {
if err := opts.Upgrader(raw); err != nil {
res.Err = fmt.Errorf("upgrade: %w", err)
return res
}
}
cfg := &utls.Config{
ServerName: sni,
InsecureSkipVerify: true, // #nosec G402 -- enumeration; we only care about handshake outcome
}
uc := utls.UClient(raw, cfg, utls.HelloCustom)
spec := buildSpec(version, cipher, sni)
if err := uc.ApplyPreset(&spec); err != nil {
res.Err = fmt.Errorf("apply-preset: %w", err)
return res
}
err = uc.Handshake()
state := uc.ConnectionState()
if err == nil {
res.Accepted = true
res.NegotiatedVersion = state.Version
res.NegotiatedCipher = state.CipherSuite
return res
}
// Some servers complete ServerHello (so we know they accepted version +
// cipher) but fail later — for example, certificate-mismatch or the
// client failing to verify. If state has a non-zero Version/CipherSuite
// matching what we offered, we still count it as accepted.
if state.Version == version && state.CipherSuite == cipher && state.CipherSuite != 0 {
res.Accepted = true
res.NegotiatedVersion = state.Version
res.NegotiatedCipher = state.CipherSuite
}
res.Err = err
return res
}
// EnumerateOptions controls Enumerate.
type EnumerateOptions struct {
// Timeout for each individual probe. Defaults to 5s when zero.
ProbeTimeout time.Duration
// Versions to try. Defaults to AllVersions when nil.
Versions []uint16
// Ciphers to try. Defaults to AllCiphers() when nil. The TLS13 flag is
// honored: TLS 1.3 ciphers are only offered with TLS 1.3 probes, and
// vice-versa.
Ciphers []CipherSuite
// Upgrader, when non-nil, is forwarded to every sub-probe (see
// ProbeOptions.Upgrader). It is invoked on a freshly-dialed connection
// before each ClientHello, so STARTTLS dialect handlers run once per
// probe, not once for the whole sweep.
Upgrader func(net.Conn) error
}
// EnumerationResult is the aggregate outcome of an enumeration sweep.
type EnumerationResult struct {
// SupportedVersions lists protocol versions for which at least one
// cipher was accepted.
SupportedVersions []uint16
// CiphersByVersion lists, per accepted version, the cipher suites the
// server agreed to negotiate.
CiphersByVersion map[uint16][]CipherSuite
}
// Enumerate sweeps a (version × cipher) matrix against addr and returns what
// the server actually accepts. Probes are performed sequentially; concurrency
// can be added later but tends to upset some middleboxes when probing too
// hard.
func Enumerate(ctx context.Context, addr, sni string, opts EnumerateOptions) (EnumerationResult, error) {
if opts.ProbeTimeout == 0 {
opts.ProbeTimeout = 5 * time.Second
}
versions := opts.Versions
if versions == nil {
versions = AllVersions
}
ciphers := opts.Ciphers
if ciphers == nil {
ciphers = AllCiphers()
}
out := EnumerationResult{
CiphersByVersion: make(map[uint16][]CipherSuite),
}
seenVersion := make(map[uint16]bool)
for _, v := range versions {
isTLS13 := v == utls.VersionTLS13
for _, c := range ciphers {
if c.TLS13 != isTLS13 {
continue
}
if err := ctx.Err(); err != nil {
return out, err
}
r := Probe(ctx, addr, sni, v, c.ID, ProbeOptions{
Timeout: opts.ProbeTimeout,
Upgrader: opts.Upgrader,
})
if !r.Accepted {
continue
}
out.CiphersByVersion[v] = append(out.CiphersByVersion[v], c)
if !seenVersion[v] {
seenVersion[v] = true
out.SupportedVersions = append(out.SupportedVersions, v)
}
}
}
return out, nil
}
// buildSpec assembles a ClientHelloSpec offering exactly one cipher and one
// protocol version. For TLS 1.3 the legacy version field stays at TLS 1.2 and
// the real version is signalled through the SupportedVersions extension, per
// RFC 8446 §4.1.2 / §4.2.1.
func buildSpec(version, cipher uint16, sni string) utls.ClientHelloSpec {
tlsVersMin := version
tlsVersMax := version
if version == utls.VersionTLS13 {
// utls inspects TLSVersMax to decide whether to drive TLS 1.3
// machinery; the on-the-wire legacy_version stays TLS 1.2.
tlsVersMin = utls.VersionTLS12
}
exts := []utls.TLSExtension{
&utls.SNIExtension{ServerName: sni},
&utls.SupportedCurvesExtension{Curves: []utls.CurveID{
utls.X25519, utls.CurveP256, utls.CurveP384, utls.CurveP521,
}},
&utls.SupportedPointsExtension{SupportedPoints: []byte{0}}, // uncompressed
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []utls.SignatureScheme{
utls.ECDSAWithP256AndSHA256, utls.ECDSAWithP384AndSHA384, utls.ECDSAWithP521AndSHA512,
utls.PSSWithSHA256, utls.PSSWithSHA384, utls.PSSWithSHA512,
utls.PKCS1WithSHA256, utls.PKCS1WithSHA384, utls.PKCS1WithSHA512,
utls.PKCS1WithSHA1, utls.ECDSAWithSHA1,
}},
&utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient},
}
if version == utls.VersionTLS13 {
exts = append(exts,
&utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13}},
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{
{Group: utls.X25519},
}},
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}},
)
}
return utls.ClientHelloSpec{
TLSVersMin: tlsVersMin,
TLSVersMax: tlsVersMax,
CipherSuites: []uint16{cipher},
CompressionMethods: []byte{0}, // null
Extensions: exts,
}
}
// ErrNoVersions is returned when an enumeration request asks for an empty set
// of versions or ciphers.
var ErrNoVersions = errors.New("tlsenum: no versions or ciphers to probe")

223
tlsenum/tlsenum_test.go Normal file
View file

@ -0,0 +1,223 @@
package tlsenum
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
stdtls "crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"net"
"os"
"testing"
"time"
utls "github.com/refraction-networking/utls"
)
// selfSignedCert returns a brand-new in-memory self-signed cert + key for
// "test.local", suitable for stdlib tls.Server.
func selfSignedCert() (stdtls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return stdtls.Certificate{}, err
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test.local"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"test.local"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
return stdtls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return stdtls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return stdtls.X509KeyPair(certPEM, keyPEM)
}
// runFakeStartTLSServer accepts one connection, expects a "STARTTLS\r\n"
// line, replies "OK\r\n", then runs a TLS handshake. It returns once the
// handshake completes (or fails) and the connection is closed.
func runFakeStartTLSServer(ln net.Listener, cert stdtls.Certificate) error {
c, err := ln.Accept()
if err != nil {
return err
}
defer c.Close()
buf := make([]byte, len("STARTTLS\r\n"))
if _, err := io.ReadFull(c, buf); err != nil {
return err
}
if string(buf) != "STARTTLS\r\n" {
return fmt.Errorf("unexpected pre-tls line: %q", string(buf))
}
if _, err := c.Write([]byte("OK\r\n")); err != nil {
return err
}
tc := stdtls.Server(c, &stdtls.Config{
Certificates: []stdtls.Certificate{cert},
MinVersion: stdtls.VersionTLS12,
})
defer tc.Close()
return tc.Handshake()
}
// liveTarget returns a host:port to enumerate against, or skips the test if
// the environment hasn't opted in. Network tests are gated behind
// TLSENUM_LIVE=1 so the unit-test suite stays hermetic.
func liveTarget(t *testing.T) (addr, sni string) {
t.Helper()
if os.Getenv("TLSENUM_LIVE") == "" {
t.Skip("set TLSENUM_LIVE=1 to run live enumeration tests")
}
host := os.Getenv("TLSENUM_HOST")
if host == "" {
host = "tls-v1-2.badssl.com"
}
port := os.Getenv("TLSENUM_PORT")
if port == "" {
port = "1012"
}
return net.JoinHostPort(host, port), host
}
func TestProbe_TLS12_AESGCM(t *testing.T) {
addr, sni := liveTarget(t)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
r := Probe(ctx, addr, sni, utls.VersionTLS12, 0xC02F /* ECDHE-RSA-AES128-GCM-SHA256 */, ProbeOptions{Timeout: 5 * time.Second})
if !r.Accepted {
t.Fatalf("expected ECDHE-RSA-AES128-GCM-SHA256 to be accepted on TLS 1.2 target; got err=%v", r.Err)
}
if r.NegotiatedVersion != utls.VersionTLS12 {
t.Fatalf("negotiated version = %x, want %x", r.NegotiatedVersion, utls.VersionTLS12)
}
}
func TestEnumerate_BasicShape(t *testing.T) {
addr, sni := liveTarget(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
res, err := Enumerate(ctx, addr, sni, EnumerateOptions{
ProbeTimeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("Enumerate: %v", err)
}
if len(res.SupportedVersions) == 0 {
t.Fatalf("no supported versions discovered")
}
for v, ciphers := range res.CiphersByVersion {
if len(ciphers) == 0 {
t.Errorf("version %s listed as supported but no ciphers recorded", VersionName(v))
}
t.Logf("%s: %d cipher(s)", VersionName(v), len(ciphers))
}
}
// TestProbe_UpgraderInvoked uses a tiny in-memory STARTTLS-style server: a
// goroutine listens, reads one "STARTTLS\r\n" line, replies "OK\r\n", then
// performs a real Go-stdlib TLS handshake. We probe through the matching
// Upgrader and assert the handshake succeeds — proving the callback runs in
// the right place between dial and ClientHello.
func TestProbe_UpgraderInvoked(t *testing.T) {
cert, err := selfSignedCert()
if err != nil {
t.Fatalf("self-signed cert: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
srvDone := make(chan error, 1)
go func() { srvDone <- runFakeStartTLSServer(ln, cert) }()
upgrader := func(c net.Conn) error {
if _, err := c.Write([]byte("STARTTLS\r\n")); err != nil {
return err
}
buf := make([]byte, 16)
n, err := c.Read(buf)
if err != nil {
return err
}
if got := string(buf[:n]); got != "OK\r\n" {
return fmt.Errorf("unexpected reply: %q", got)
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r := Probe(ctx, ln.Addr().String(), "test.local",
utls.VersionTLS12, 0xC02B, /* ECDHE-ECDSA-AES128-GCM-SHA256 (matches the P-256 cert) */
ProbeOptions{Timeout: 3 * time.Second, Upgrader: upgrader})
if !r.Accepted {
t.Fatalf("expected handshake to succeed through upgrader; err=%v", r.Err)
}
if r.NegotiatedVersion != utls.VersionTLS12 {
t.Fatalf("negotiated %#x, want %#x", r.NegotiatedVersion, utls.VersionTLS12)
}
if err := <-srvDone; err != nil {
t.Logf("fake server done with: %v", err) // accept clean close from utls
}
}
func TestProbe_UpgraderError(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
c, _ := ln.Accept()
if c != nil {
c.Close()
}
}()
wantErr := errors.New("plaintext refused starttls")
r := Probe(context.Background(), ln.Addr().String(), "x",
utls.VersionTLS12, 0xC02F,
ProbeOptions{Timeout: 2 * time.Second, Upgrader: func(net.Conn) error { return wantErr }})
if r.Accepted {
t.Fatalf("expected probe to fail when upgrader returns error")
}
if r.Err == nil || !errors.Is(r.Err, wantErr) {
t.Fatalf("expected wrapped upgrader error, got %v", r.Err)
}
}
func TestVersionName(t *testing.T) {
cases := map[uint16]string{
utls.VersionTLS10: "TLS 1.0",
utls.VersionTLS11: "TLS 1.1",
utls.VersionTLS12: "TLS 1.2",
utls.VersionTLS13: "TLS 1.3",
0x9999: "0x9999",
}
for v, want := range cases {
if got := VersionName(v); got != want {
t.Errorf("VersionName(%#x) = %q, want %q", v, got, want)
}
}
}