From 485c5a4a1d69df1f3177738cbcfc424835c70af9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 11:25:26 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 17 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 29 ++ README.md | 190 ++++++++++ checker/collect.go | 563 +++++++++++++++++++++++++++++ checker/collect_test.go | 307 ++++++++++++++++ checker/definition.go | 85 +++++ checker/interactive.go | 134 +++++++ checker/interactive_test.go | 128 +++++++ checker/issues.go | 288 +++++++++++++++ checker/issues_test.go | 326 +++++++++++++++++ checker/probe_test.go | 370 +++++++++++++++++++ checker/provider.go | 72 ++++ checker/provider_test.go | 101 ++++++ checker/report.go | 663 +++++++++++++++++++++++++++++++++++ checker/report_test.go | 240 +++++++++++++ checker/rule.go | 214 +++++++++++ checker/rule_test.go | 294 ++++++++++++++++ checker/rules_null_mx.go | 33 ++ checker/rules_simple.go | 39 +++ checker/rules_tls_quality.go | 36 ++ checker/smtp.go | 230 ++++++++++++ checker/smtp_extra_test.go | 192 ++++++++++ checker/smtp_test.go | 177 ++++++++++ checker/tls_related.go | 158 +++++++++ checker/tls_related_test.go | 200 +++++++++++ checker/types.go | 188 ++++++++++ go.mod | 17 + go.sum | 18 + main.go | 27 ++ plugin/plugin.go | 20 ++ 33 files changed, 5407 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/collect_test.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/interactive_test.go create mode 100644 checker/issues.go create mode 100644 checker/issues_test.go create mode 100644 checker/probe_test.go create mode 100644 checker/provider.go create mode 100644 checker/provider_test.go create mode 100644 checker/report.go create mode 100644 checker/report_test.go create mode 100644 checker/rule.go create mode 100644 checker/rule_test.go create mode 100644 checker/rules_null_mx.go create mode 100644 checker/rules_simple.go create mode 100644 checker/rules_tls_quality.go create mode 100644 checker/smtp.go create mode 100644 checker/smtp_extra_test.go create mode 100644 checker/smtp_test.go create mode 100644 checker/tls_related.go create mode 100644 checker/tls_related_test.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..612da46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-smtp +checker-smtp.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c036fac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-smtp . + +FROM scratch +COPY --from=builder /checker-smtp /checker-smtp +USER 65534:65534 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/checker-smtp", "-healthcheck"] +ENTRYPOINT ["/checker-smtp"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1910280 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-smtp +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2f3be4f --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +checker-smtp +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + +This product reuses the tls.endpoint.v1 discovery contract from the +checker-tls project (https://git.happydns.org/happyDomain/checker-tls), +licensed under the MIT License. + +This product includes software from the miekg/dns project +(https://github.com/miekg/dns), licensed under the BSD 3-Clause License: + + Copyright (c) 2009 The Go Authors. All rights reserved. + Copyright (c) 2011 Miek Gieben. All rights reserved. + Copyright (c) 2014 CloudFlare. All rights reserved. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..95c545b --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# checker-smtp + +Deep SMTP checker for the MX-based inbound mail service of a +[happyDomain](https://www.happydomain.org/) domain. + +For every MX target of the zone, it performs the live probes a human +operator would run with `swaks` or `telnet … 25`: TCP connect, ESMTP +banner & EHLO, STARTTLS negotiation, mail-transaction (null sender, +postmaster, open-relay) probes, reverse DNS / FCrDNS, extension +inventory, and IPv4/IPv6 coverage. The result is an actionable HTML +report whose "What to fix" panel foregrounds the most common real-world +failures rather than burying them in endpoint tabs. + +## Scope + +This checker probes the **inbound** side of the domain's mail service: +it connects to each MX target and exercises the SMTP server's +protocol-level posture (banner, EHLO, STARTTLS handshake, mail +transactions stopped at RCPT, reverse DNS, IPv4/IPv6 coverage…). + +It does **not** test outbound deliverability: SPF/DKIM/DMARC alignment, +ARC, BIMI, spam scoring (SpamAssassin/rspamd), blacklist status, header +hygiene or message content are not evaluated here. Those require +actually emitting a message from the domain and analysing what arrives; +that is the job of `checker-happydeliver`, which drives a +[happyDeliver](https://git.nemunai.re/happyDomain/happyDeliver) instance. + +In short: **`checker-smtp` answers "can this domain *receive* mail +correctly?"**, while **`checker-happydeliver` answers "does mail this +domain *sends* land in the inbox?"**. + +TLS certificate chain / SAN / expiry / cipher posture is also **out of scope**: +a dedicated TLS checker handles that. This checker only confirms STARTTLS +completes and records the negotiated TLS version/cipher for context. + +We publish each MX target as a `DiscoveryEntry` of type +`tls.endpoint.v1` (contract: `git.happydns.org/checker-tls/contract`) +with `STARTTLS="smtp"` and `RequireSTARTTLS=false` (opportunistic for +port 25; make it required by publishing MTA-STS or DANE in dedicated +checkers). `checker-tls` picks up those entries and runs certificate +posture on the same connection our probe just validated; the resulting +`tls_probes` observations are folded back into our rule aggregation and +HTML report via `ObservationGetter.GetRelated` / `ReportContext.Related`, +so a bad certificate on an MX shows up on the SMTP service page, not +only in a separate TLS view. + +## What it checks + +### DNS posture + +1. MX records published? (RFC 7505 null-MX is recognised and reported as INFO) +2. MX target is a hostname, **not** an IP literal (RFC 5321 § 5.1). +3. MX target is **not** a CNAME (RFC 5321 § 5.1). +4. MX target resolves (A and/or AAAA). +5. Implicit-MX fallback warned about. + +### Per-endpoint (port 25, for each A/AAAA of each MX) + +6. TCP reachability. +7. SMTP 220 banner, captured verbatim; announced hostname parsed. +8. ESMTP EHLO (fallback to HELO detected and flagged). +9. Extension inventory: STARTTLS, PIPELINING, 8BITMIME, SMTPUTF8, + CHUNKING, DSN, ENHANCEDSTATUSCODES, SIZE, AUTH. +10. `AUTH` advertised *before* STARTTLS (credentials-over-plaintext risk). +11. STARTTLS negotiation and TLS version/cipher recorded (no cert checks; handed off to `checker-tls`). +12. Post-TLS EHLO: extensions may expand after the upgrade; we union them. +13. Reverse DNS (PTR) present for each IP. +14. Forward-confirmed reverse DNS (FCrDNS): PTR's forward resolution must include our IP (Gmail / Outlook / Yahoo reject without this). +15. Null sender acceptance (`MAIL FROM:<>`; RFC 5321 mandates this for bounces). +16. Postmaster mailbox acceptance (`RCPT TO:`; RFC 5321 § 4.5.1). +17. **Open-relay probe** (`MAIL FROM:` then `RCPT TO:`; a 2xx indicates an open relay). The probe stops at RCPT; `DATA` is never sent. +18. IPv4 / IPv6 coverage. + +The rule emits one `CheckState` per derived issue, with `Subject` set +to the offending endpoint (`ip:25`) or MX target so the host can +correlate findings across runs. When nothing is wrong the rule emits a +single OK state; an RFC 7505 null MX collapses to a single INFO state. +The HTML report renders a domain-level "What to fix" panel (sorted +crit → warn → info) plus one collapsible section per probed endpoint, +open by default when something is wrong. + +## Most common failures and how the report addresses them + +| Symptom | Issue code | Report message | +|-------------------------------------------|-----------------------------|----------------| +| MX target is a CNAME | `smtp.mx.cname` | CRIT, fix suggests replacing CNAME with A/AAAA | +| No STARTTLS on any endpoint | `smtp.all_no_starttls` | CRIT, fix mentions Postfix/Exim settings and MTA-STS/DANE next steps | +| `AUTH` advertised over plaintext port 25 | `smtp.auth.plaintext` | CRIT, fix suggests `smtpd_tls_auth_only=yes` / moving auth to 587 | +| `postmaster@` rejected | `smtp.postmaster.rejected` | CRIT, cites RFC 5321 § 4.5.1 | +| Bounces (`MAIL FROM:<>`) rejected | `smtp.null_sender.rejected` | CRIT | +| Missing PTR or FCrDNS mismatch | `smtp.ptr.missing`, `smtp.fcrdns.mismatch` | WARN, names Gmail/Outlook/Yahoo impact | +| Open relay | `smtp.open_relay` | CRIT (the endpoint panel also shows a red "OPEN RELAY" badge in the summary) | + +## Usage + +### Standalone HTTP server + +```bash +make +./checker-smtp -listen :8080 +``` + +The standalone binary also exposes a browser-friendly `GET /check` page +(via the SDK's `CheckerInteractive` interface): enter a domain, submit, +and the same `Collect` → `Evaluate` → HTML-report pipeline runs without +needing a happyDomain instance in front. MX records are looked up live; +no zone payload is required. + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-smtp +``` + +### happyDomain plugin + +```bash +make plugin +``` + +## Options + +| Scope | Id | Default | Description | +|-------|-----------------------|--------------------------------|-------------| +| Run | `domain` | (none) | Domain to test (auto-filled from the service). | +| Run | `timeout` | `12` | Per-endpoint timeout, in seconds. | +| Run | `helo_name` | `mx-checker.happydomain.org` | Hostname announced in EHLO/HELO. Pick a name with valid A/AAAA and PTR. | +| Run | `test_null_sender` | `true` | Probe `MAIL FROM:<>` (RFC 5321 DSN acceptance). | +| Run | `test_postmaster` | `true` | Probe `RCPT TO:` (RFC 5321 § 4.5.1). | +| Run | `test_open_relay` | `true` | Probe `RCPT TO:` to detect open relays. | +| Run | `test_probe_address` | `postmaster@example.com` | Recipient used for the open-relay probe. Automatically overridden when equal to the tested domain. | + +Applies to services of type `svcs.MXs` (the DNS-level MX record set). + +## Safety / hosted deployment + +The checker connects out to arbitrary SMTP servers on port 25 with the +host's IP, and concatenates user-supplied values (`domain`, `helo_name`, +`test_probe_address`) into SMTP commands. Two consequences worth +considering before exposing the standalone server (or its `GET /check` +form) to untrusted users: + +- **CRLF / SMTP-command injection** is mitigated: `domain` and + `helo_name` are validated as hostnames, and `test_probe_address` is + validated as an addr-spec. Inputs containing CR, LF, `<`, `>` or other + SMTP metacharacters are rejected before any command is written to the + wire. +- **Probe-from-our-IP abuse vector** remains: anyone who can reach the + service can have it open SMTP connections to any host:25, optionally + with an attacker-chosen RCPT (the open-relay probe). This is + functionally similar to an SSRF: outbound traffic appears to come + from the checker's address and may trigger blocklisting or abuse + reports against the operator. When deploying publicly, gate access + behind authentication, add per-IP rate limiting, and consider + restricting target domains (e.g. only domains owned by the requester) + before exposing the form. The happyDomain plugin path is unaffected: + targets there are always the MXs of the zone the user already + controls. + +## Design notes + +- **Why not `net/smtp`?** The standard library's client hides the banner + text, muxes multiline responses into a single string, and does not + expose the pre- vs post-TLS extension set separately. A bespoke + ~200-line SMTP client (see `checker/smtp.go`) gives us verbatim + responses for every step, which is what operators want to see in a + diagnostic report. +- **Why stop at RCPT?** The open-relay, null-sender and postmaster + probes all end at RCPT and emit RSET before the next transaction. We + never send `DATA`, so no mail is actually delivered and no bounces are + generated. A receiving server that accepts a spoofed RCPT but would + have rejected the message at DATA is still reported as open relay (a + sensible choice for a posture check). +- **Certificate posture via `checker-tls`.** MX SMTP on port 25 is + opportunistic, so we do not verify the certificate ourselves. Each + probed MX target is published as a `tls.endpoint.v1` discovery entry + with `STARTTLS="smtp"`. `checker-tls`'s resulting observations are + folded back into the rule aggregation and the HTML report via the + SDK's `GetRelated` / `ReportContext.Related` path (same pattern as + `checker-xmpp`). +- **No DANE / MTA-STS checks here.** These are policy surfaces, not + connection-time behaviours, and deserve their own checkers + (`checker-dane` on TLSA records, `checker-mta-sts` on the TXT/HTTPS + policy artefact). This checker answers the question "does the MX + actually work?"; policy enforcement layers on top. + +## License + +MIT (see `LICENSE`). Third-party attributions in `NOTICE`. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..2765b2c --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,563 @@ +package checker + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const defaultEHLOName = "mx-checker.happydomain.org" +const smtpPort = 25 + +// mxServiceBody mirrors the shape of svcs.MXs in happyDomain. We decode +// it by hand (rather than importing the happyDomain server) to keep the +// build surface small (checker-srv follows the same pattern). +type mxServiceBody struct { + MXs []struct { + Hdr struct { + Name string `json:"Name"` + } `json:"Hdr"` + Preference uint16 `json:"Preference"` + Mx string `json:"Mx"` + } `json:"mx"` +} + +func (p *smtpProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain") + domain = strings.TrimSuffix(strings.TrimSpace(domain), ".") + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + if !isValidHostname(domain) { + return nil, fmt.Errorf("invalid domain %q", domain) + } + + helo, _ := sdk.GetOption[string](opts, "helo_name") + helo = strings.TrimSpace(helo) + if helo == "" { + helo = defaultEHLOName + } + if !isValidHostname(helo) { + return nil, fmt.Errorf("invalid helo_name %q", helo) + } + + timeoutSecs := sdk.GetFloatOption(opts, "timeout", 12) + if timeoutSecs < 1 { + timeoutSecs = 12 + } + perEndpoint := time.Duration(timeoutSecs * float64(time.Second)) + + testNull := sdk.GetBoolOption(opts, "test_null_sender", true) + testPostmaster := sdk.GetBoolOption(opts, "test_postmaster", true) + testOpenRelay := sdk.GetBoolOption(opts, "test_open_relay", true) + probeRcpt, _ := sdk.GetOption[string](opts, "test_probe_address") + probeRcpt = strings.TrimSpace(probeRcpt) + if probeRcpt == "" || !isValidMailbox(probeRcpt) { + probeRcpt = "postmaster@example.com" + } + // Never use a recipient inside the domain under test; that would turn + // an accept into a false-positive open relay. + if addrDomain, _, ok := splitMail(probeRcpt); ok && strings.EqualFold(addrDomain, domain) { + probeRcpt = "postmaster@example.com" + } + + data := &SMTPData{ + Domain: domain, + RunAt: time.Now().UTC().Format(time.RFC3339), + } + + resolver := net.DefaultResolver + lookupCtx, cancel := context.WithTimeout(ctx, perEndpoint) + defer cancel() + + // Prefer the service body when supplied (authoritative, already + // parsed from the zone); fall back to a live MX lookup. + var mxTargets []mxTargetRaw + if body, ok := sdk.GetOption[json.RawMessage](opts, "service"); ok && len(body) > 0 { + mxTargets = parseServiceBody(body) + } + if len(mxTargets) == 0 { + var err error + mxTargets, err = lookupMX(lookupCtx, resolver, domain) + if err != nil { + data.MX.Error = err.Error() + } + } + + // RFC 7505 null MX sentinel. + if len(mxTargets) == 1 && (mxTargets[0].Target == "" || mxTargets[0].Target == ".") && mxTargets[0].Preference == 0 { + data.MX.NullMX = true + return data, nil + } + + if len(mxTargets) == 0 && data.MX.Error == "" { + // Implicit MX (RFC 5321 § 5.1): fall back to the bare domain. + data.MX.ImplicitMX = true + mxTargets = []mxTargetRaw{{Preference: 0, Target: domain}} + } + + for _, t := range mxTargets { + rec := MXRecord{ + Preference: t.Preference, + Target: strings.TrimSuffix(t.Target, "."), + } + if rec.Target == "" { + continue + } + if ip := net.ParseIP(rec.Target); ip != nil { + rec.IsIPLiteral = true + } + // Detect CNAME (RFC 5321 § 5.1 forbids MX → CNAME). + if !rec.IsIPLiteral { + if cname, err := resolver.LookupCNAME(lookupCtx, rec.Target); err == nil { + canon := strings.TrimSuffix(cname, ".") + if canon != "" && !strings.EqualFold(canon, rec.Target) { + rec.IsCNAME = true + rec.CNAMEChain = []string{rec.Target, canon} + } + } + } + + if rec.IsIPLiteral { + if ip := net.ParseIP(rec.Target); ip != nil { + if v4 := ip.To4(); v4 != nil { + rec.IPv4 = append(rec.IPv4, v4.String()) + } else { + rec.IPv6 = append(rec.IPv6, ip.String()) + } + } + } else { + ips, err := resolver.LookupIPAddr(lookupCtx, rec.Target) + if err != nil { + rec.ResolveError = err.Error() + } + for _, ip := range ips { + if v4 := ip.IP.To4(); v4 != nil { + rec.IPv4 = append(rec.IPv4, v4.String()) + } else { + rec.IPv6 = append(rec.IPv6, ip.IP.String()) + } + } + } + data.MX.Records = append(data.MX.Records, rec) + } + + // Probe every (target, ip) pair. + for _, rec := range data.MX.Records { + for _, ip := range rec.IPv4 { + ep := probeEndpoint(ctx, probeInputs{ + target: rec.Target, + ip: ip, + isV6: false, + domain: domain, + heloName: helo, + timeout: perEndpoint, + testNull: testNull, + testPostmaster: testPostmaster, + testOpenRelay: testOpenRelay, + openRelayRcpt: probeRcpt, + }) + data.Endpoints = append(data.Endpoints, ep) + } + for _, ip := range rec.IPv6 { + ep := probeEndpoint(ctx, probeInputs{ + target: rec.Target, + ip: ip, + isV6: true, + domain: domain, + heloName: helo, + timeout: perEndpoint, + testNull: testNull, + testPostmaster: testPostmaster, + testOpenRelay: testOpenRelay, + openRelayRcpt: probeRcpt, + }) + data.Endpoints = append(data.Endpoints, ep) + } + } + + computeCoverage(data) + + return data, nil +} + +type mxTargetRaw struct { + Preference uint16 + Target string +} + +// parseServiceBody extracts the MX list from a happyDomain svcs.MXs +// payload. Returns nil when the payload doesn't look like one; we fall +// back to a live DNS lookup in that case. +func parseServiceBody(raw json.RawMessage) []mxTargetRaw { + // happyDomain wraps the body in ServiceMessage{Type, Service:}. + // We accept either the full ServiceMessage or the body directly. + var envelope struct { + Type string `json:"_svctype"` + Service json.RawMessage `json:"Service"` + } + var body json.RawMessage + if err := json.Unmarshal(raw, &envelope); err == nil && len(envelope.Service) > 0 { + body = envelope.Service + } else { + body = raw + } + var parsed mxServiceBody + if err := json.Unmarshal(body, &parsed); err != nil { + return nil + } + out := make([]mxTargetRaw, 0, len(parsed.MXs)) + for _, m := range parsed.MXs { + out = append(out, mxTargetRaw{Preference: m.Preference, Target: m.Mx}) + } + return out +} + +// lookupMX runs a DNS MX query and returns the records, or nil when +// NXDOMAIN / no records (so the caller can trigger the implicit-MX path). +func lookupMX(ctx context.Context, r *net.Resolver, domain string) ([]mxTargetRaw, error) { + records, err := r.LookupMX(ctx, dns.Fqdn(domain)) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return nil, nil + } + // net.LookupMX returns an error on the RFC 7505 null-MX sentinel + // because "." fails host validation. Surface it as a synthetic + // record so the caller can detect the null-MX case. + if strings.Contains(err.Error(), "cannot unmarshal DNS message") { + return []mxTargetRaw{{Preference: 0, Target: "."}}, nil + } + return nil, err + } + out := make([]mxTargetRaw, 0, len(records)) + for _, m := range records { + out = append(out, mxTargetRaw{Preference: m.Pref, Target: strings.TrimSuffix(m.Host, ".")}) + } + return out, nil +} + +type probeInputs struct { + target, ip, domain, heloName string + isV6 bool + timeout time.Duration + testNull, testPostmaster bool + testOpenRelay bool + openRelayRcpt string +} + +func probeEndpoint(ctx context.Context, in probeInputs) EndpointProbe { + start := time.Now() + ep := EndpointProbe{ + Target: in.target, + Port: smtpPort, + IP: in.ip, + IsIPv6: in.isV6, + Address: net.JoinHostPort(in.ip, strconv.Itoa(smtpPort)), + } + defer func() { ep.ElapsedMS = time.Since(start).Milliseconds() }() + + // Reverse DNS: orthogonal to the SMTP connection, so we run it even + // if the connection later fails. + ptrCtx, ptrCancel := context.WithTimeout(ctx, in.timeout) + names, err := net.DefaultResolver.LookupAddr(ptrCtx, in.ip) + ptrCancel() + switch { + case err != nil: + ep.PTRError = err.Error() + case len(names) == 0: + ep.PTRError = "no PTR records" + default: + ep.PTR = strings.TrimSuffix(names[0], ".") + // FCrDNS: PTR's forward lookup must include our IP. + fwdCtx, fwdCancel := context.WithTimeout(ctx, in.timeout) + ips, ferr := net.DefaultResolver.LookupIPAddr(fwdCtx, ep.PTR) + fwdCancel() + if ferr == nil { + for _, a := range ips { + if a.IP.String() == in.ip || a.IP.Equal(net.ParseIP(in.ip)) { + ep.FCrDNSPass = true + break + } + } + } + } + + dialCtx, cancel := context.WithTimeout(ctx, in.timeout) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(dialCtx, "tcp", ep.Address) + if err != nil { + ep.Error = "tcp: " + err.Error() + return ep + } + ep.TCPConnected = true + _ = conn.SetDeadline(time.Now().Add(in.timeout)) + + sc := newSMTPConn(conn, in.timeout) + // One defer covers both the plaintext and post-STARTTLS cases: after + // swap() the smtpConn owns the tls.Conn whose Close propagates to the + // underlying TCP fd, so a separate `defer conn.Close()` would only + // double-close the same descriptor. + defer sc.close() + + // Read the banner (220). + code, text, _, err := sc.readResponse() + if err != nil { + ep.Error = "banner: " + err.Error() + return ep + } + ep.BannerReceived = true + ep.BannerCode = code + ep.BannerLine = strings.TrimSpace(strings.ReplaceAll(text, "\n", " | ")) + ep.BannerHostname = parseBanner(text) + if code != 220 { + ep.Error = fmt.Sprintf("banner: unexpected code %d", code) + return ep + } + + // EHLO (fall back to HELO on 5xx). + _, text, lines, err := sc.cmd("EHLO " + in.heloName) + if err != nil { + ep.Error = "ehlo: " + err.Error() + return ep + } + if lines[0][0] == '5' { + // Try HELO. + _, _, heloLines, herr := sc.cmd("HELO " + in.heloName) + if herr != nil || len(heloLines) == 0 || heloLines[0][0] != '2' { + ep.Error = "ehlo/helo both rejected" + return ep + } + ep.EHLOReceived = true + ep.EHLOFallbackHELO = true + ep.EHLOHostname = strings.TrimSpace(strings.SplitN(text, " ", 2)[0]) + return ep + } + ep.EHLOReceived = true + greeting, exts := parseEHLO(lines) + ep.EHLOHostname = greeting + ep.Extensions = exts + idx := buildExtensions(exts) + + ep.STARTTLSOffered = idx.has("STARTTLS") + ep.HasPipelining = idx.has("PIPELINING") + ep.Has8BITMIME = idx.has("8BITMIME") + ep.HasSMTPUTF8 = idx.has("SMTPUTF8") + ep.HasCHUNKING = idx.has("CHUNKING") + ep.HasDSN = idx.has("DSN") + ep.HasENHANCEDCODE = idx.has("ENHANCEDSTATUSCODES") + ep.SizeLimit = idx.parseSize() + ep.AUTHPreTLS = idx.parseAuth() + + // STARTTLS. + if ep.STARTTLSOffered { + code, _, _, terr := sc.cmd("STARTTLS") + if terr == nil && code == 220 { + tlsConn := tls.Client(conn, tlsProbeConfig(in.target)) + _ = tlsConn.SetDeadline(time.Now().Add(in.timeout)) + if herr := tlsConn.Handshake(); herr != nil { + ep.Error = "tls-handshake: " + herr.Error() + return ep + } + ep.STARTTLSUpgraded = true + state := tlsConn.ConnectionState() + ep.TLSVersion = tls.VersionName(state.Version) + ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + sc.swap(tlsConn) + // Re-EHLO over TLS (mandatory per RFC 3207). + _, _, lines2, eerr := sc.cmd("EHLO " + in.heloName) + if eerr == nil && len(lines2) > 0 && lines2[0][0] == '2' { + _, exts2 := parseEHLO(lines2) + ep.PostTLSExtensions = exts2 + idx2 := buildExtensions(exts2) + ep.AUTHPostTLS = idx2.parseAuth() + // Union the feature flags: some servers only advertise + // 8BITMIME, PIPELINING, etc. after STARTTLS. + if !ep.HasPipelining { + ep.HasPipelining = idx2.has("PIPELINING") + } + if !ep.Has8BITMIME { + ep.Has8BITMIME = idx2.has("8BITMIME") + } + if !ep.HasSMTPUTF8 { + ep.HasSMTPUTF8 = idx2.has("SMTPUTF8") + } + if !ep.HasCHUNKING { + ep.HasCHUNKING = idx2.has("CHUNKING") + } + if !ep.HasDSN { + ep.HasDSN = idx2.has("DSN") + } + if !ep.HasENHANCEDCODE { + ep.HasENHANCEDCODE = idx2.has("ENHANCEDSTATUSCODES") + } + if ep.SizeLimit == 0 { + ep.SizeLimit = idx2.parseSize() + } + } + } else if terr != nil { + ep.Error = "starttls: " + terr.Error() + return ep + } else { + ep.Error = fmt.Sprintf("starttls: unexpected code %d", code) + // Don't bail; still run transactional probes over plaintext + // so the operator sees what the server does without TLS. + } + } + + // RCPT-level probes. Each runs in its own MAIL/RSET pair so an earlier + // reject does not mask later ones. + runRCPT := func(from, to string) (int, string) { + code, text, _, err := sc.cmd("MAIL FROM:<" + from + ">") + if err != nil { + return -1, err.Error() + } + if code != 250 { + defer sc.cmd("RSET") + return code, strings.TrimSpace(text) + } + code, text, _, err = sc.cmd("RCPT TO:<" + to + ">") + sc.cmd("RSET") + if err != nil { + return -1, err.Error() + } + return code, strings.TrimSpace(text) + } + + if in.testNull { + c, t := runRCPT("", "postmaster@"+in.domain) + ok := c >= 200 && c < 300 + ep.NullSenderAccepted = &ok + ep.NullSenderResponse = fmt.Sprintf("%d %s", c, t) + } + if in.testPostmaster { + from := "checker@" + in.heloName + c, t := runRCPT(from, "postmaster@"+in.domain) + ok := c >= 200 && c < 300 + ep.PostmasterAccepted = &ok + ep.PostmasterResponse = fmt.Sprintf("%d %s", c, t) + } + if in.testOpenRelay && in.openRelayRcpt != "" { + from := "checker@" + in.heloName + c, t := runRCPT(from, in.openRelayRcpt) + ok := c >= 200 && c < 300 + ep.OpenRelay = &ok + ep.OpenRelayResponse = fmt.Sprintf("%d %s", c, t) + ep.OpenRelayRecipient = in.openRelayRcpt + } + + return ep +} + +func splitMail(addr string) (domain, local string, ok bool) { + at := strings.LastIndex(addr, "@") + if at <= 0 || at == len(addr)-1 { + return "", "", false + } + return addr[at+1:], addr[:at], true +} + +// isValidHostname rejects anything that could smuggle SMTP commands +// (CR, LF, spaces, angle brackets) or is otherwise not a plausible +// hostname. We use it on every user-supplied value that ends up +// concatenated into an SMTP command line. +func isValidHostname(s string) bool { + if s == "" || len(s) > 253 { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '.', r == '-': + continue + default: + return false + } + } + return true +} + +// isValidMailbox accepts a conservative subset of RFC 5321 addr-spec: +// printable ASCII local-part with no SMTP metacharacters, followed by +// "@" and a valid hostname. Quoted local-parts are not allowed. +func isValidMailbox(s string) bool { + at := strings.LastIndex(s, "@") + if at <= 0 || at == len(s)-1 { + return false + } + local := s[:at] + if len(local) > 64 { + return false + } + for i := 0; i < len(local); i++ { + c := local[i] + if c <= 0x20 || c >= 0x7f { + return false + } + switch c { + case '<', '>', '(', ')', '[', ']', ',', ';', ':', '"', '\\', '@': + return false + } + } + return isValidHostname(s[at+1:]) +} + +func computeCoverage(data *SMTPData) { + if len(data.Endpoints) == 0 { + return + } + allSTARTTLS := true + allAcceptMail := true + for _, ep := range data.Endpoints { + if ep.TCPConnected { + data.Coverage.AnyReachable = true + if ep.IsIPv6 { + data.Coverage.HasIPv6 = true + } else { + data.Coverage.HasIPv4 = true + } + } + if ep.BannerReceived { + data.Coverage.AnyBanner = true + } + if ep.EHLOReceived { + data.Coverage.AnyEHLO = true + } + if ep.STARTTLSUpgraded { + data.Coverage.AnySTARTTLS = true + } else { + allSTARTTLS = false + } + // An endpoint "accepts mail" when the null-sender probe, if run, + // was accepted and the postmaster probe, if run, was accepted. + acc := true + if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted { + acc = false + } + if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted { + acc = false + } + if !ep.EHLOReceived { + acc = false + } + if !acc { + allAcceptMail = false + } + } + data.Coverage.AllSTARTTLS = allSTARTTLS + data.Coverage.AllAcceptMail = allAcceptMail +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..1dbf614 --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,307 @@ +package checker + +import ( + "context" + "encoding/json" + "net" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestIsValidHostname(t *testing.T) { + good := []string{"example.com", "mx-1.example.com", "a.b.c.d", "MX.EXAMPLE.COM", "1.2.3.4"} + for _, s := range good { + if !isValidHostname(s) { + t.Errorf("expected %q valid", s) + } + } + bad := []string{ + "", "a b.com", "a\r\nb.com", "a\nb.com", ".com", + "under_score.com", "a@b.com", "spaces .com", strings.Repeat("a", 254), + } + for _, s := range bad { + if isValidHostname(s) { + t.Errorf("expected %q invalid", s) + } + } +} + +func TestIsValidMailbox(t *testing.T) { + good := []string{"a@b.com", "user.name+tag@mx.example.com", "postmaster@example.org"} + for _, s := range good { + if !isValidMailbox(s) { + t.Errorf("expected %q valid", s) + } + } + bad := []string{ + "", + "@example.com", + "a@", + "a b@example.com", // space in local + "a\r\n@example.com", // CRLF + "a@example.com", // bracket in local + "a,b@example.com", // comma in local + "\"quoted\"@example.com", // quoted local + "a@with space.com", + "a@.com", + strings.Repeat("a", 65) + "@example.com", // too-long local + } + for _, s := range bad { + if isValidMailbox(s) { + t.Errorf("expected %q invalid", s) + } + } +} + +func TestCollect_RejectsInvalidDomain(t *testing.T) { + p := &smtpProvider{} + _, err := p.Collect(context.Background(), sdk.CheckerOptions{"domain": "evil\r\nMAIL FROM:<>"}) + if err == nil || !strings.Contains(err.Error(), "invalid domain") { + t.Errorf("expected invalid-domain error, got %v", err) + } +} + +func TestCollect_RejectsInvalidHELO(t *testing.T) { + p := &smtpProvider{} + _, err := p.Collect(context.Background(), sdk.CheckerOptions{ + "domain": "example.com", + "helo_name": "evil\r\nRSET", + }) + if err == nil || !strings.Contains(err.Error(), "invalid helo_name") { + t.Errorf("expected invalid-helo error, got %v", err) + } +} + +func TestCollect_RewritesInvalidProbeAddress(t *testing.T) { + p := &smtpProvider{} + body := json.RawMessage(`{"mx":[{"Preference":0,"Mx":"."}]}`) // null MX → returns immediately + out, err := p.Collect(context.Background(), sdk.CheckerOptions{ + "domain": "example.com", + "service": body, + "timeout": 1.0, + "test_probe_address": "evil\r\nMAIL FROM:", + }) + if err != nil { + t.Fatalf("collect: %v", err) + } + if !out.(*SMTPData).MX.NullMX { + t.Error("expected null MX path") + } +} + +func TestSplitMail_MoreCases(t *testing.T) { + cases := []struct { + in string + ok bool + domain, local string + }{ + {"a@b.com", true, "b.com", "a"}, + {"a@b@c.com", true, "c.com", "a@b"}, // last @ wins + {"", false, "", ""}, + {"@", false, "", ""}, + } + for _, c := range cases { + d, l, ok := splitMail(c.in) + if ok != c.ok || d != c.domain || l != c.local { + t.Errorf("splitMail(%q) = (%q,%q,%v), want (%q,%q,%v)", c.in, d, l, ok, c.domain, c.local, c.ok) + } + } +} + +func TestComputeCoverage_Empty(t *testing.T) { + d := &SMTPData{} + computeCoverage(d) + if d.Coverage.AnyReachable { + t.Errorf("empty endpoints should not be reachable") + } +} + +func TestComputeCoverage_AllPath(t *testing.T) { + yes := true + d := &SMTPData{ + Endpoints: []EndpointProbe{ + {IP: "1.2.3.4", IsIPv6: false, TCPConnected: true, BannerReceived: true, EHLOReceived: true, STARTTLSUpgraded: true, NullSenderAccepted: &yes, PostmasterAccepted: &yes}, + {IP: "2001:db8::1", IsIPv6: true, TCPConnected: true, BannerReceived: true, EHLOReceived: true, STARTTLSUpgraded: true, NullSenderAccepted: &yes, PostmasterAccepted: &yes}, + }, + } + computeCoverage(d) + c := d.Coverage + if !c.HasIPv4 || !c.HasIPv6 || !c.AnyReachable || !c.AnyBanner || !c.AnyEHLO || !c.AnySTARTTLS || !c.AllSTARTTLS || !c.AllAcceptMail { + t.Errorf("expected all coverage flags set, got %+v", c) + } +} + +func TestComputeCoverage_PartialSTARTTLS(t *testing.T) { + yes := true + d := &SMTPData{ + Endpoints: []EndpointProbe{ + {IP: "1.2.3.4", TCPConnected: true, BannerReceived: true, EHLOReceived: true, STARTTLSUpgraded: true, NullSenderAccepted: &yes, PostmasterAccepted: &yes}, + {IP: "1.2.3.5", TCPConnected: true, BannerReceived: true, EHLOReceived: true, STARTTLSUpgraded: false, NullSenderAccepted: &yes, PostmasterAccepted: &yes}, + }, + } + computeCoverage(d) + if !d.Coverage.AnySTARTTLS { + t.Error("any STARTTLS expected") + } + if d.Coverage.AllSTARTTLS { + t.Error("not all STARTTLS") + } +} + +func TestComputeCoverage_RejectedMailFlipsAccept(t *testing.T) { + no := false + yes := true + d := &SMTPData{ + Endpoints: []EndpointProbe{ + {IP: "1.2.3.4", TCPConnected: true, BannerReceived: true, EHLOReceived: true, STARTTLSUpgraded: true, NullSenderAccepted: &no, PostmasterAccepted: &yes}, + }, + } + computeCoverage(d) + if d.Coverage.AllAcceptMail { + t.Error("AllAcceptMail should be false when null sender rejected") + } +} + +func TestComputeCoverage_NoEHLOFlipsAccept(t *testing.T) { + d := &SMTPData{ + Endpoints: []EndpointProbe{ + {IP: "1.2.3.4", TCPConnected: true, BannerReceived: true, EHLOReceived: false}, + }, + } + computeCoverage(d) + if d.Coverage.AllAcceptMail { + t.Error("no EHLO must drop AllAcceptMail") + } +} + +func TestParseServiceBody_FullEnvelope(t *testing.T) { + type mxitem struct { + Hdr struct{ Name string } `json:"Hdr"` + Preference uint16 + Mx string + } + body := struct { + MXs []mxitem `json:"mx"` + }{ + MXs: []mxitem{ + {Preference: 10, Mx: "mx1.example.com."}, + {Preference: 20, Mx: "mx2.example.com."}, + }, + } + envelope := struct { + Type string `json:"_svctype"` + Service json.RawMessage `json:"Service"` + }{Type: "svcs.MXs"} + envelope.Service, _ = json.Marshal(body) + raw, _ := json.Marshal(envelope) + + out := parseServiceBody(raw) + if len(out) != 2 { + t.Fatalf("got %d", len(out)) + } + if out[0].Preference != 10 || out[0].Target != "mx1.example.com." { + t.Errorf("[0]: %+v", out[0]) + } +} + +func TestParseServiceBody_BareBody(t *testing.T) { + raw := json.RawMessage(`{"mx":[{"Preference":5,"Mx":"mx.example.com."}]}`) + out := parseServiceBody(raw) + if len(out) != 1 || out[0].Preference != 5 { + t.Errorf("got %+v", out) + } +} + +func TestParseServiceBody_BadJSON(t *testing.T) { + if got := parseServiceBody(json.RawMessage(`not json`)); got != nil { + t.Errorf("expected nil, got %+v", got) + } +} + +func TestParseServiceBody_NoMX(t *testing.T) { + raw := json.RawMessage(`{"mx":[]}`) + out := parseServiceBody(raw) + if out == nil { + t.Errorf("empty array should yield empty slice, not nil") + } + if len(out) != 0 { + t.Errorf("got %+v", out) + } +} + +func TestLookupMX_NXDomainBecomesEmpty(t *testing.T) { + // Use a TLD-style label that fails fast and cleanly. We rely on the + // system resolver returning IsNotFound for invalid.example.invalid; + // if the local resolver is unusual, the test is skipped. + r := &net.Resolver{} + out, err := lookupMX(context.Background(), r, "invalid.example.invalid") + if err != nil { + t.Skipf("resolver returned a different error: %v", err) + } + if out != nil { + t.Errorf("expected nil for NXDOMAIN, got %+v", out) + } +} + +func TestCollect_RejectsEmptyDomain(t *testing.T) { + p := &smtpProvider{} + _, err := p.Collect(context.Background(), sdk.CheckerOptions{"domain": " "}) + if err == nil || !strings.Contains(err.Error(), "domain is required") { + t.Errorf("expected domain-required error, got %v", err) + } +} + +func TestCollect_NullMXFromService(t *testing.T) { + p := &smtpProvider{} + body := json.RawMessage(`{"mx":[{"Preference":0,"Mx":"."}]}`) + out, err := p.Collect(context.Background(), sdk.CheckerOptions{ + "domain": "example.com", + "service": body, + "timeout": 1.0, + }) + if err != nil { + t.Fatalf("collect: %v", err) + } + d, ok := out.(*SMTPData) + if !ok { + t.Fatalf("type: %T", out) + } + if !d.MX.NullMX { + t.Errorf("expected NullMX=true, got %+v", d.MX) + } + if len(d.Endpoints) != 0 { + t.Errorf("null MX should not probe, got %d endpoints", len(d.Endpoints)) + } +} + +func TestCollect_RewritesProbeToAvoidLocalDomain(t *testing.T) { + // Use a tiny timeout so the probe attempt against the bogus IP + // fails fast; we only assert on the rewriting behavior, which + // happens before any network call. + p := &smtpProvider{} + body := json.RawMessage(`{"mx":[{"Preference":10,"Mx":"127.255.255.255"}]}`) // unlikely to be an MX target + opts := sdk.CheckerOptions{ + "domain": "example.com", + "service": body, + "timeout": 1.0, + "test_probe_address": "victim@example.com", // same domain → must be rewritten + "test_open_relay": false, + "test_null_sender": false, + "test_postmaster": false, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := p.Collect(ctx, opts) + if err != nil { + t.Fatalf("collect: %v", err) + } + d := out.(*SMTPData) + for _, ep := range d.Endpoints { + if strings.Contains(ep.OpenRelayRecipient, "example.com") { + t.Errorf("probe recipient leaked into the local domain: %q", ep.OpenRelayRecipient) + } + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..40da51a --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,85 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is reported in CheckerDefinition.Version. Overridden at build time +// by main / plugin. +var Version = "built-in" + +func (p *smtpProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "smtp", + Name: "Inbound SMTP (MX posture)", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"svcs.MXs"}, + }, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeySMTP}, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain", + Type: "string", + Label: "Domain", + AutoFill: sdk.AutoFillDomainName, + Required: true, + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 12, + }, + { + Id: "helo_name", + Type: "string", + Label: "EHLO hostname", + Placeholder: "mx-checker.happydomain.org", + Default: "mx-checker.happydomain.org", + Description: "The hostname announced in EHLO/HELO. Use a name that resolves and has a valid PTR; some large providers tarpit or reject probes from unresolvable EHLO names.", + }, + { + Id: "test_null_sender", + Type: "bool", + Label: "Probe null sender (MAIL FROM:<>)", + Default: true, + Description: "RFC 5321 mandates that bounces with an empty envelope sender are accepted; servers that reject <> cannot receive DSNs.", + }, + { + Id: "test_postmaster", + Type: "bool", + Label: "Probe RCPT TO:", + Default: true, + Description: "RFC 5321 § 4.5.1 requires every host to accept mail for . The probe stops at RCPT, no DATA is transmitted.", + }, + { + Id: "test_open_relay", + Type: "bool", + Label: "Probe open-relay posture", + Default: true, + Description: "Attempts MAIL FROM: RCPT TO:. A 2xx on a recipient outside the tested domain indicates an open relay. The probe stops at RCPT; no DATA is transmitted.", + }, + { + Id: "test_probe_address", + Type: "string", + Label: "Open-relay probe recipient", + Placeholder: "postmaster@example.com", + Default: "postmaster@example.com", + Description: "Recipient used for the open-relay probe. Must be a mailbox outside the tested domain.", + }, + }, + }, + Rules: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 6 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..5b972db --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,134 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm implements server.Interactive: the human-facing form +// exposed at GET /check when the checker runs as a standalone binary. +func (p *smtpProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Domain", + Placeholder: "example.com", + Required: true, + Description: "The email domain to probe. MX records are looked up live.", + }, + { + Id: "helo_name", + Type: "string", + Label: "EHLO hostname", + Placeholder: defaultEHLOName, + Default: defaultEHLOName, + Description: "The hostname announced in EHLO/HELO. Use a name that resolves and has a valid PTR.", + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 12, + }, + { + Id: "test_null_sender", + Type: "bool", + Label: "Probe null sender (MAIL FROM:<>)", + Default: true, + }, + { + Id: "test_postmaster", + Type: "bool", + Label: "Probe RCPT TO:", + Default: true, + }, + { + Id: "test_open_relay", + Type: "bool", + Label: "Probe open-relay posture", + Default: true, + }, + { + Id: "test_probe_address", + Type: "string", + Label: "Open-relay probe recipient", + Placeholder: "postmaster@example.com", + Default: "postmaster@example.com", + }, + } +} + +// ParseForm implements server.Interactive: turns the submitted HTML +// form into the CheckerOptions that Collect expects. No AutoFill is +// performed by a host here; Collect falls back to a live MX lookup when +// no "service" payload is supplied, so forwarding the bare domain is +// enough. +func (p *smtpProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + domain = strings.TrimSuffix(domain, ".") + if domain == "" { + return nil, errors.New("domain is required") + } + if !isValidHostname(domain) { + return nil, errors.New("invalid domain") + } + + opts := sdk.CheckerOptions{ + "domain": domain, + } + + if helo := strings.TrimSpace(r.FormValue("helo_name")); helo != "" { + if !isValidHostname(helo) { + return nil, errors.New("invalid helo_name") + } + opts["helo_name"] = helo + } + if raw := strings.TrimSpace(r.FormValue("timeout")); raw != "" { + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, errors.New("timeout must be a number") + } + opts["timeout"] = v + } + opts["test_null_sender"] = parseBool(r, "test_null_sender", true) + opts["test_postmaster"] = parseBool(r, "test_postmaster", true) + opts["test_open_relay"] = parseBool(r, "test_open_relay", true) + if probe := strings.TrimSpace(r.FormValue("test_probe_address")); probe != "" { + if !isValidMailbox(probe) { + return nil, errors.New("invalid test_probe_address") + } + opts["test_probe_address"] = probe + } + + return opts, nil +} + +// parseBool reads a checkbox-style field. HTML forms omit unchecked +// checkboxes entirely, so a missing key means false, but only if the +// form was actually submitted (presence of the sentinel); we use the +// default when the field is not present at all. +func parseBool(r *http.Request, key string, def bool) bool { + if _, ok := r.Form[key]; !ok { + // When the form has been parsed and _no_ checkbox was checked, + // we still want false rather than the default. Detect a + // submitted form by the presence of the required "domain" key. + if _, submitted := r.Form["domain"]; submitted { + return false + } + return def + } + v := strings.ToLower(strings.TrimSpace(r.FormValue(key))) + switch v { + case "", "0", "false", "off", "no": + return false + default: + return true + } +} diff --git a/checker/interactive_test.go b/checker/interactive_test.go new file mode 100644 index 0000000..5e53590 --- /dev/null +++ b/checker/interactive_test.go @@ -0,0 +1,128 @@ +//go:build standalone + +package checker + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func formRequest(values url.Values) *httptest.ResponseRecorder { + _ = values + return httptest.NewRecorder() +} + +func TestRenderForm_HasAllFields(t *testing.T) { + fields := (&smtpProvider{}).RenderForm() + got := map[string]bool{} + for _, f := range fields { + got[f.Id] = true + } + for _, want := range []string{"domain", "helo_name", "timeout", "test_null_sender", "test_postmaster", "test_open_relay", "test_probe_address"} { + if !got[want] { + t.Errorf("missing field %q", want) + } + } +} + +func TestParseForm_Defaults(t *testing.T) { + v := url.Values{"domain": {"example.com"}} + r := httptest.NewRequest("POST", "/", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + opts, err := (&smtpProvider{}).ParseForm(r) + if err != nil { + t.Fatalf("ParseForm: %v", err) + } + if opts["domain"] != "example.com" { + t.Errorf("domain: %v", opts["domain"]) + } + // Submitted form with no checkbox checks → false (per parseBool sentinel). + if opts["test_null_sender"].(bool) { + t.Error("expected test_null_sender=false on submitted form without checkbox") + } +} + +func TestParseForm_TrimsAndStripsTrailingDot(t *testing.T) { + v := url.Values{"domain": {" example.com. "}} + r := httptest.NewRequest("POST", "/", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + opts, err := (&smtpProvider{}).ParseForm(r) + if err != nil { + t.Fatalf("ParseForm: %v", err) + } + if opts["domain"] != "example.com" { + t.Errorf("expected normalized domain, got %v", opts["domain"]) + } +} + +func TestParseForm_RejectsEmptyDomain(t *testing.T) { + r := httptest.NewRequest("POST", "/", strings.NewReader("")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if _, err := (&smtpProvider{}).ParseForm(r); err == nil { + t.Fatal("expected error on empty domain") + } +} + +func TestParseForm_RejectsNonNumericTimeout(t *testing.T) { + v := url.Values{"domain": {"example.com"}, "timeout": {"banana"}} + r := httptest.NewRequest("POST", "/", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if _, err := (&smtpProvider{}).ParseForm(r); err == nil { + t.Fatal("expected error on non-numeric timeout") + } +} + +func TestParseForm_PassesThroughCheckboxes(t *testing.T) { + v := url.Values{ + "domain": {"example.com"}, + "timeout": {"5"}, + "helo_name": {"mx-checker.example.com"}, + "test_null_sender": {"on"}, + "test_postmaster": {"true"}, + "test_open_relay": {"yes"}, + "test_probe_address": {"postmaster@example.org"}, + } + r := httptest.NewRequest("POST", "/", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + opts, err := (&smtpProvider{}).ParseForm(r) + if err != nil { + t.Fatalf("ParseForm: %v", err) + } + if opts["timeout"].(float64) != 5 { + t.Errorf("timeout: %v", opts["timeout"]) + } + if !opts["test_null_sender"].(bool) || !opts["test_postmaster"].(bool) || !opts["test_open_relay"].(bool) { + t.Errorf("checkboxes: %+v", opts) + } + if opts["helo_name"] != "mx-checker.example.com" { + t.Errorf("helo_name: %v", opts["helo_name"]) + } + if opts["test_probe_address"] != "postmaster@example.org" { + t.Errorf("probe addr: %v", opts["test_probe_address"]) + } +} + +func TestParseBool_DefaultWhenNotSubmitted(t *testing.T) { + r := httptest.NewRequest("GET", "/?", nil) + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if !parseBool(r, "feature", true) { + t.Error("missing key on non-submitted form should yield default") + } +} + +func TestParseBool_FalsyValues(t *testing.T) { + cases := []string{"0", "false", "off", "no", "FALSE"} + for _, c := range cases { + r := httptest.NewRequest("GET", "/?domain=x&feature="+c, nil) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse: %v", err) + } + if parseBool(r, "feature", true) { + t.Errorf("value %q should be false", c) + } + } +} diff --git a/checker/issues.go b/checker/issues.go new file mode 100644 index 0000000..dd4e92b --- /dev/null +++ b/checker/issues.go @@ -0,0 +1,288 @@ +package checker + +import ( + "fmt" + "strings" +) + +// deriveIssues distils observation data into a sorted list of findings +// that the rule reduces to a CheckState and the HTML report renders as +// the "What to fix" panel. +// +// The function is pure: it reads data and returns a slice, so it is +// trivially testable and stable across runs. +func deriveIssues(data *SMTPData) []Issue { + var issues []Issue + + // 1. MX / DNS scope. + switch { + case data.MX.NullMX: + issues = append(issues, Issue{ + Code: CodeNullMX, + Severity: SeverityInfo, + Message: "Domain advertises a null MX (RFC 7505): it explicitly refuses all email.", + Fix: "If this is intentional (the domain sends but does not receive mail), no action needed. Otherwise, remove the '.' MX record and publish real mail exchangers.", + }) + return issues + case data.MX.Error != "": + issues = append(issues, Issue{ + Code: CodeMXLookupFailed, + Severity: SeverityCrit, + Message: "MX lookup failed: " + data.MX.Error, + Fix: "Check the authoritative DNS servers for this domain.", + }) + case data.MX.ImplicitMX: + issues = append(issues, Issue{ + Code: CodeImplicitMX, + Severity: SeverityWarn, + Message: "No MX record published; senders will fall back to the A/AAAA of the bare domain (implicit MX).", + Fix: "Publish explicit MX records so you can separate web and mail servers and so anti-spam signals apply correctly.", + }) + case len(data.MX.Records) == 0: + issues = append(issues, Issue{ + Code: CodeNoMX, + Severity: SeverityCrit, + Message: "No MX record found for " + data.Domain + ".", + Fix: "Publish at least one MX record pointing to a reachable mail server, or a null MX ('.' with preference 0) if the domain must not receive mail.", + }) + } + + for _, rec := range data.MX.Records { + if rec.IsIPLiteral { + issues = append(issues, Issue{ + Code: CodeMXIPLiteral, + Severity: SeverityCrit, + Message: fmt.Sprintf("MX target %q is an IP address; RFC 5321 § 5.1 requires a hostname.", rec.Target), + Fix: "Publish an A/AAAA record under a hostname (e.g. mail." + data.Domain + ") and point the MX at that name.", + Target: rec.Target, + }) + } + if rec.IsCNAME { + issues = append(issues, Issue{ + Code: CodeMXCNAME, + Severity: SeverityWarn, + Message: fmt.Sprintf("MX target %q is a CNAME (chain: %s). RFC 5321 § 5.1 forbids this.", rec.Target, strings.Join(rec.CNAMEChain, " → ")), + Fix: "Replace the CNAME with an A/AAAA record directly on the MX target, or point the MX at the CNAME's canonical name.", + Target: rec.Target, + }) + } + if rec.ResolveError != "" { + issues = append(issues, Issue{ + Code: CodeMXResolveFailed, + Severity: SeverityCrit, + Message: fmt.Sprintf("Failed to resolve MX target %q: %s", rec.Target, rec.ResolveError), + Fix: "Check that " + rec.Target + " has valid A/AAAA records.", + Target: rec.Target, + }) + } + if !rec.IsIPLiteral && rec.ResolveError == "" && len(rec.IPv4) == 0 && len(rec.IPv6) == 0 { + issues = append(issues, Issue{ + Code: CodeNoAddresses, + Severity: SeverityCrit, + Message: fmt.Sprintf("MX target %q has no A or AAAA records.", rec.Target), + Fix: "Add at least one A or AAAA record for " + rec.Target + ".", + Target: rec.Target, + }) + } + } + + // 2. Endpoint-level issues. + anyConnected := false + anySTARTTLS := false + for _, ep := range data.Endpoints { + if !ep.TCPConnected { + issues = append(issues, Issue{ + Code: CodeTCPUnreachable, + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot reach %s (%s): %s.", ep.Address, ep.Target, ep.Error), + Fix: "Verify firewall rules and that an SMTP service listens on port 25 of " + ep.IP + ".", + Endpoint: ep.Address, + Target: ep.Target, + }) + continue + } + anyConnected = true + + if !ep.BannerReceived { + issues = append(issues, Issue{ + Code: CodeBannerMissing, + Severity: SeverityCrit, + Message: fmt.Sprintf("No SMTP banner received on %s (%s).", ep.Address, ep.Target), + Fix: "Confirm that the service on port 25 is actually SMTP and is not rate-limiting or blackholing the probe.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } else if ep.BannerCode != 220 { + issues = append(issues, Issue{ + Code: CodeBannerInvalid, + Severity: SeverityCrit, + Message: fmt.Sprintf("Banner on %s returned code %d (expected 220).", ep.Address, ep.BannerCode), + Fix: "A non-220 greeting means the MTA refuses the connection. Check logs for tarpit/rate-limit rules triggered by our EHLO hostname.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.BannerReceived && !ep.EHLOReceived { + issues = append(issues, Issue{ + Code: CodeEHLOFailed, + Severity: SeverityCrit, + Message: fmt.Sprintf("EHLO rejected on %s: %s.", ep.Address, ep.Error), + Fix: "Check the MTA's HELO access rules. Most servers require the EHLO name to resolve; run the checker with a working EHLO hostname.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } else if ep.EHLOFallbackHELO { + issues = append(issues, Issue{ + Code: CodeEHLOFallback, + Severity: SeverityWarn, + Message: fmt.Sprintf("Server on %s only accepts HELO, not EHLO.", ep.Address), + Fix: "Upgrade to an ESMTP-capable configuration. EHLO is mandatory for STARTTLS, PIPELINING, SIZE, DSN, 8BITMIME, SMTPUTF8…", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + + // STARTTLS posture: MX SMTP is opportunistic, but "no TLS at all" + // is a strong signal that the operator forgot to configure it. + if ep.EHLOReceived && !ep.STARTTLSOffered { + issues = append(issues, Issue{ + Code: CodeSTARTTLSMissing, + Severity: SeverityCrit, + Message: fmt.Sprintf("STARTTLS not advertised on %s; inbound mail will be delivered in cleartext.", ep.Address), + Fix: "Enable STARTTLS in your MTA (Postfix: smtpd_tls_security_level=may and a valid cert; Exim: tls_advertise_hosts=*). This is a prerequisite for DANE / MTA-STS.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.STARTTLSOffered && !ep.STARTTLSUpgraded { + issues = append(issues, Issue{ + Code: CodeSTARTTLSFailed, + Severity: SeverityCrit, + Message: fmt.Sprintf("STARTTLS advertised but the TLS handshake failed on %s: %s.", ep.Address, ep.Error), + Fix: "Check the server certificate and protocol versions with the TLS checker. A common cause is an expired certificate or disabled TLS 1.0/1.1 on a client that only speaks older versions.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.STARTTLSUpgraded { + anySTARTTLS = true + } + + // AUTH offered without TLS is a classic misconfiguration: + // a client can send credentials in cleartext. + if len(ep.AUTHPreTLS) > 0 { + issues = append(issues, Issue{ + Code: CodeAUTHOverPlain, + Severity: SeverityCrit, + Message: fmt.Sprintf("AUTH (%s) advertised on %s *before* STARTTLS; credentials can be observed on the wire.", strings.Join(ep.AUTHPreTLS, ","), ep.Address), + Fix: "Disable SMTP AUTH on port 25 entirely (it's not supposed to be used for submission) or gate it behind smtpd_tls_auth_only=yes. Submission belongs on port 587.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + + // PTR / FCrDNS, strongly weighted by anti-spam. + if ep.PTR == "" { + issues = append(issues, Issue{ + Code: CodePTRMissing, + Severity: SeverityWarn, + Message: fmt.Sprintf("No PTR record for %s. Many receivers (Gmail, Outlook, Yahoo) reject mail from IPs without reverse DNS.", ep.IP), + Fix: "Set a PTR record on " + ep.IP + " at your hosting provider. It should match the EHLO name announced by the MTA.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } else if !ep.FCrDNSPass { + issues = append(issues, Issue{ + Code: CodeFCrDNSMismatch, + Severity: SeverityWarn, + Message: fmt.Sprintf("FCrDNS fails on %s: PTR %q does not resolve back to this IP.", ep.IP, ep.PTR), + Fix: "Either fix the PTR to point at a hostname whose A/AAAA resolves to " + ep.IP + ", or add the missing A/AAAA on the existing PTR target.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + + // Null sender / postmaster / open relay. + if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted { + issues = append(issues, Issue{ + Code: CodeNullSenderReject, + Severity: SeverityCrit, + Message: fmt.Sprintf("Server on %s rejects MAIL FROM:<> (response: %s).", ep.Address, ep.NullSenderResponse), + Fix: "RFC 5321 mandates that bounces (DSNs) use the null sender. Refusing it means you cannot receive bounce reports, and any legitimate DSN will be lost.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted { + issues = append(issues, Issue{ + Code: CodePostmasterReject, + Severity: SeverityCrit, + Message: fmt.Sprintf("Server on %s rejects RCPT TO: (response: %s).", ep.Address, data.Domain, ep.PostmasterResponse), + Fix: "RFC 5321 § 4.5.1 requires every SMTP receiver to accept mail for postmaster. Create the mailbox (or an alias to the team's inbox).", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.OpenRelay != nil && *ep.OpenRelay { + issues = append(issues, Issue{ + Code: CodeOpenRelay, + Severity: SeverityCrit, + Message: fmt.Sprintf("OPEN RELAY: %s accepted RCPT TO:<%s> from the probe. Spammers can use this server to send arbitrary mail.", ep.Address, ep.OpenRelayRecipient), + Fix: "Restrict relaying to authenticated users only. In Postfix set smtpd_relay_restrictions accordingly; in Exim, require `acl_smtp_rcpt` to check local domains first.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + + // Minor posture issues. + if ep.EHLOReceived && !ep.HasPipelining { + issues = append(issues, Issue{ + Code: CodeNoPipelining, + Severity: SeverityInfo, + Message: fmt.Sprintf("PIPELINING not advertised on %s.", ep.Address), + Fix: "Enable ESMTP PIPELINING; it materially reduces the number of network round-trips for each delivery.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + if ep.EHLOReceived && !ep.Has8BITMIME { + issues = append(issues, Issue{ + Code: CodeNo8BITMIME, + Severity: SeverityInfo, + Message: fmt.Sprintf("8BITMIME not advertised on %s.", ep.Address), + Fix: "Enable 8BITMIME support; without it, senders must MIME-encode non-ASCII bodies or risk rewrites.", + Endpoint: ep.Address, + Target: ep.Target, + }) + } + } + + if len(data.Endpoints) > 0 && !anyConnected { + issues = append(issues, Issue{ + Code: CodeAllEndpointsDown, + Severity: SeverityCrit, + Message: "None of the MX targets accepted a TCP connection on port 25.", + Fix: "Confirm the mail servers are running and that their network path is open to the internet on port 25.", + }) + } + if anyConnected && !anySTARTTLS { + issues = append(issues, Issue{ + Code: CodeAllNoSTARTTLS, + Severity: SeverityCrit, + Message: "No MX endpoint advertises a working STARTTLS. All inbound mail is delivered in cleartext.", + Fix: "Enable STARTTLS on every MX endpoint (a valid certificate is needed). Once this is done, consider publishing MTA-STS and TLSA/DANE records for strict enforcement.", + }) + } + + // IPv6 coverage (info only, since IPv4 is still the dominant path). + if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { + issues = append(issues, Issue{ + Code: CodeNoIPv6, + Severity: SeverityInfo, + Message: "No MX endpoint reachable over IPv6.", + Fix: "Publish AAAA records for your MX targets; Gmail, Outlook and Yahoo prefer IPv6-capable receivers.", + }) + } + + return issues +} diff --git a/checker/issues_test.go b/checker/issues_test.go new file mode 100644 index 0000000..65e93d6 --- /dev/null +++ b/checker/issues_test.go @@ -0,0 +1,326 @@ +package checker + +import ( + "strings" + "testing" +) + +// hasIssue reports whether the issue list contains an entry with the +// given code. Used by the table-driven tests below. +func hasIssue(issues []Issue, code string) bool { + for _, is := range issues { + if is.Code == code { + return true + } + } + return false +} + +func issueByCode(issues []Issue, code string) *Issue { + for i := range issues { + if issues[i].Code == code { + return &issues[i] + } + } + return nil +} + +func TestDeriveIssues_NullMXShortCircuits(t *testing.T) { + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{NullMX: true, Records: []MXRecord{{Target: "."}}}, + Endpoints: []EndpointProbe{ + {Target: "mx", IP: "1.2.3.4", Address: "1.2.3.4:25"}, // would normally yield issues + }, + } + issues := deriveIssues(d) + if len(issues) != 1 { + t.Fatalf("null MX should short-circuit; got %d issues", len(issues)) + } + if issues[0].Code != CodeNullMX { + t.Errorf("want CodeNullMX, got %q", issues[0].Code) + } + if issues[0].Severity != SeverityInfo { + t.Errorf("null MX is informational, got %q", issues[0].Severity) + } +} + +func TestDeriveIssues_MXLookupFailed(t *testing.T) { + d := &SMTPData{Domain: "x", MX: MXLookup{Error: "servfail"}} + issues := deriveIssues(d) + if !hasIssue(issues, CodeMXLookupFailed) { + t.Fatalf("expected mx lookup failed, got %+v", issues) + } +} + +func TestDeriveIssues_ImplicitMX(t *testing.T) { + d := &SMTPData{Domain: "x", MX: MXLookup{ImplicitMX: true}} + issues := deriveIssues(d) + if !hasIssue(issues, CodeImplicitMX) { + t.Fatalf("expected implicit MX issue") + } + is := issueByCode(issues, CodeImplicitMX) + if is.Severity != SeverityWarn { + t.Errorf("implicit MX should be warn, got %q", is.Severity) + } +} + +func TestDeriveIssues_NoMX(t *testing.T) { + d := &SMTPData{Domain: "x"} + issues := deriveIssues(d) + if !hasIssue(issues, CodeNoMX) { + t.Fatalf("expected no-mx issue") + } +} + +func TestDeriveIssues_MXIPLiteral(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{ + {Preference: 10, Target: "192.0.2.1", IsIPLiteral: true, IPv4: []string{"192.0.2.1"}}, + }}, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeMXIPLiteral) { + t.Fatalf("expected ip-literal issue") + } +} + +func TestDeriveIssues_MXResolveFailed(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{ + {Preference: 10, Target: "mx.x", ResolveError: "nxdomain"}, + }}, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeMXResolveFailed) { + t.Fatalf("expected resolve-failed issue") + } +} + +func TestDeriveIssues_MXNoAddresses(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{ + {Preference: 10, Target: "mx.x"}, // no IPs, no error + }}, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeNoAddresses) { + t.Fatalf("expected no-addresses issue") + } +} + +func TestDeriveIssues_TCPUnreachable(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{ + {Target: "mx.x", IPv4: []string{"1.2.3.4"}}, + }}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", Error: "connection refused"}, + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeTCPUnreachable) { + t.Fatalf("expected tcp-unreachable") + } + if !hasIssue(issues, CodeAllEndpointsDown) { + t.Fatalf("expected all-endpoints-down summary") + } +} + +func TestDeriveIssues_BannerMissingAndInvalid(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true}, + {Target: "mx.x", IP: "1.2.3.5", Address: "1.2.3.5:25", TCPConnected: true, BannerReceived: true, BannerCode: 421}, + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeBannerMissing) { + t.Errorf("expected banner-missing") + } + if !hasIssue(issues, CodeBannerInvalid) { + t.Errorf("expected banner-invalid") + } +} + +func TestDeriveIssues_EHLOFailedAndFallback(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220}, + {Target: "mx.x", IP: "1.2.3.5", Address: "1.2.3.5:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, EHLOFallbackHELO: true}, + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeEHLOFailed) { + t.Errorf("expected ehlo-failed") + } + if !hasIssue(issues, CodeEHLOFallback) { + t.Errorf("expected ehlo-fallback") + } +} + +func TestDeriveIssues_STARTTLSMissingAndFailed(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true}, // no STARTTLS offered + {Target: "mx.x", IP: "1.2.3.5", Address: "1.2.3.5:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, STARTTLSOffered: true, Error: "ssl bad"}, // offered but not upgraded + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeSTARTTLSMissing) { + t.Errorf("expected starttls missing") + } + if !hasIssue(issues, CodeSTARTTLSFailed) { + t.Errorf("expected starttls failed") + } + if !hasIssue(issues, CodeAllNoSTARTTLS) { + t.Errorf("expected summary all-no-starttls") + } +} + +func TestDeriveIssues_AUTHOverPlain(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, AUTHPreTLS: []string{"PLAIN", "LOGIN"}}, + }, + } + issues := deriveIssues(d) + is := issueByCode(issues, CodeAUTHOverPlain) + if is == nil { + t.Fatalf("expected auth-over-plain issue") + } + if !strings.Contains(is.Message, "PLAIN") || !strings.Contains(is.Message, "LOGIN") { + t.Errorf("auth message should list mechanisms, got %q", is.Message) + } +} + +func TestDeriveIssues_PTRAndFCrDNS(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + {Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true}, + {Target: "mx.x", IP: "1.2.3.5", Address: "1.2.3.5:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, PTR: "wrong.example.com", FCrDNSPass: false}, + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodePTRMissing) { + t.Errorf("expected ptr-missing") + } + if !hasIssue(issues, CodeFCrDNSMismatch) { + t.Errorf("expected fcrdns-mismatch") + } +} + +func TestDeriveIssues_NullSenderRejected(t *testing.T) { + no := false + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + { + Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + PTR: "mx.x", FCrDNSPass: true, HasPipelining: true, Has8BITMIME: true, + NullSenderAccepted: &no, NullSenderResponse: "550 nope", + }, + }, + } + issues := deriveIssues(d) + is := issueByCode(issues, CodeNullSenderReject) + if is == nil { + t.Fatalf("expected null-sender-reject") + } + if is.Severity != SeverityCrit { + t.Errorf("severity: want crit, got %q", is.Severity) + } +} + +func TestDeriveIssues_PostmasterRejected(t *testing.T) { + no := false + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + { + Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + PTR: "mx.x", FCrDNSPass: true, HasPipelining: true, Has8BITMIME: true, + PostmasterAccepted: &no, PostmasterResponse: "550 no postmaster", + }, + }, + } + if !hasIssue(deriveIssues(d), CodePostmasterReject) { + t.Fatalf("expected postmaster-reject") + } +} + +func TestDeriveIssues_NoIPv6(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Coverage: Coverage{HasIPv4: true, HasIPv6: false}, + } + if !hasIssue(deriveIssues(d), CodeNoIPv6) { + t.Fatalf("expected no-ipv6 info issue") + } +} + +func TestDeriveIssues_NoExtensionInfoIssues(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Coverage: Coverage{HasIPv4: true, HasIPv6: true}, + Endpoints: []EndpointProbe{ + { + Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + PTR: "mx.x", FCrDNSPass: true, + HasPipelining: false, Has8BITMIME: false, + }, + }, + } + issues := deriveIssues(d) + if !hasIssue(issues, CodeNoPipelining) { + t.Errorf("expected no-pipelining info") + } + if !hasIssue(issues, CodeNo8BITMIME) { + t.Errorf("expected no-8bitmime info") + } +} + +func TestDeriveIssues_HappyPath(t *testing.T) { + yes := true + no := false + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{Records: []MXRecord{{Preference: 10, Target: "mx.example.com", IPv4: []string{"1.2.3.4"}, IPv6: []string{"2001:db8::1"}}}}, + Coverage: Coverage{HasIPv4: true, HasIPv6: true}, + Endpoints: []EndpointProbe{ + { + Target: "mx.example.com", IP: "1.2.3.4", Address: "1.2.3.4:25", + TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + PTR: "mx.example.com", FCrDNSPass: true, + HasPipelining: true, Has8BITMIME: true, + NullSenderAccepted: &yes, PostmasterAccepted: &yes, OpenRelay: &no, + }, + }, + } + issues := deriveIssues(d) + if len(issues) != 0 { + t.Errorf("happy-path should have no issues, got: %+v", issues) + } +} diff --git a/checker/probe_test.go b/checker/probe_test.go new file mode 100644 index 0000000..6e2bde0 --- /dev/null +++ b/checker/probe_test.go @@ -0,0 +1,370 @@ +package checker + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// fakeSMTPServer is a tiny scripted SMTP responder. Each line of the +// `script` is matched against the incoming command; an empty script +// uses a default healthy server (banner, EHLO with STARTTLS, RSET, QUIT). +type fakeSMTPServer struct { + t *testing.T + listener net.Listener + addr string + port uint16 + tlsCfg *tls.Config + wg sync.WaitGroup + + // behaviour switches + offerSTARTTLS bool + failHandshake bool + rejectEHLO bool + rejectMAIL bool + rejectRCPT bool + authPreTLS bool + noBanner bool +} + +func newFakeSMTPServer(t *testing.T) *fakeSMTPServer { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + host, portStr, _ := net.SplitHostPort(l.Addr().String()) + p, _ := strconv.Atoi(portStr) + cfg := selfSignedTLSConfig(t) + srv := &fakeSMTPServer{ + t: t, + listener: l, + addr: host, + port: uint16(p), + tlsCfg: cfg, + offerSTARTTLS: true, + } + return srv +} + +func selfSignedTLSConfig(t *testing.T) *tls.Config { + t.Helper() + priv, 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: "fake.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("cert: %v", err) + } + keyDER, _ := x509.MarshalECPrivateKey(priv) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + pair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatalf("x509keypair: %v", err) + } + return &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12} +} + +func (s *fakeSMTPServer) start() { + s.wg.Add(1) + go func() { + defer s.wg.Done() + conn, err := s.listener.Accept() + if err != nil { + return + } + s.handle(conn) + }() +} + +func (s *fakeSMTPServer) handle(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + w := func(line string) { _, _ = conn.Write([]byte(line + "\r\n")) } + + if s.noBanner { + // Just close after a tiny delay. + time.Sleep(10 * time.Millisecond) + return + } + w("220 fake.test ESMTP") + + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + up := strings.ToUpper(line) + switch { + case strings.HasPrefix(up, "EHLO"): + if s.rejectEHLO { + w("502 EHLO not supported") + continue + } + w("250-fake.test") + w("250-PIPELINING") + w("250-SIZE 52428800") + w("250-8BITMIME") + if s.authPreTLS { + w("250-AUTH PLAIN LOGIN") + } + if s.offerSTARTTLS { + w("250-STARTTLS") + } + w("250 HELP") + case strings.HasPrefix(up, "HELO"): + w("250 fake.test") + case up == "STARTTLS": + if !s.offerSTARTTLS { + w("502 not advertised") + continue + } + w("220 ready") + tlsConn := tls.Server(conn, s.tlsCfg) + if s.failHandshake { + // Respond 220 but don't actually upgrade: close to make + // the handshake fail on the client side. + time.Sleep(10 * time.Millisecond) + return + } + if err := tlsConn.Handshake(); err != nil { + return + } + conn = tlsConn + br = bufio.NewReader(conn) + w = func(line string) { _, _ = conn.Write([]byte(line + "\r\n")) } + case strings.HasPrefix(up, "MAIL FROM"): + if s.rejectMAIL { + w("550 sender rejected") + } else { + w("250 sender ok") + } + case strings.HasPrefix(up, "RCPT TO"): + if s.rejectRCPT { + w("550 rcpt rejected") + } else { + w("250 rcpt ok") + } + case up == "RSET": + w("250 reset") + case up == "QUIT": + w("221 bye") + return + default: + w("502 unrecognized") + } + } +} + +func (s *fakeSMTPServer) stop() { + _ = s.listener.Close() + s.wg.Wait() +} + +// runProbe wraps probeEndpoint with the fake server's address; tests +// then assert on the EndpointProbe that comes back. +func (s *fakeSMTPServer) runProbe(t *testing.T, in probeInputs) EndpointProbe { + t.Helper() + if in.target == "" { + in.target = "127.0.0.1" + } + if in.ip == "" { + in.ip = "127.0.0.1" + } + if in.timeout == 0 { + in.timeout = 5 * time.Second + } + if in.heloName == "" { + in.heloName = "client.test" + } + // Override the canonical probe with a custom port via Address. + // probeEndpoint hard-codes port 25, so we monkey-patch by dialing + // ourselves: we directly invoke the helper functions instead. + return probeAt(t, s.addr, s.port, in) +} + +// probeAt replicates probeEndpoint against an arbitrary (host, port). +// We can't reuse probeEndpoint directly because it hard-codes port 25. +// Keeping the body in lockstep with collect.go is the test's job. +func probeAt(t *testing.T, host string, port uint16, in probeInputs) EndpointProbe { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), in.timeout) + defer cancel() + addr := net.JoinHostPort(host, strconv.Itoa(int(port))) + ep := EndpointProbe{Target: in.target, Port: port, IP: in.ip, Address: addr} + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + ep.Error = "tcp: " + err.Error() + return ep + } + ep.TCPConnected = true + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(in.timeout)) + sc := newSMTPConn(conn, in.timeout) + + code, text, _, err := sc.readResponse() + if err != nil { + ep.Error = "banner: " + err.Error() + return ep + } + ep.BannerReceived = true + ep.BannerCode = code + ep.BannerLine = text + if code != 220 { + ep.Error = "banner not 220" + return ep + } + _, _, lines, err := sc.cmd("EHLO " + in.heloName) + if err != nil { + ep.Error = "ehlo: " + err.Error() + return ep + } + if lines[0][0] == '5' { + ep.Error = "ehlo rejected" + return ep + } + ep.EHLOReceived = true + _, exts := parseEHLO(lines) + idx := buildExtensions(exts) + ep.STARTTLSOffered = idx.has("STARTTLS") + ep.HasPipelining = idx.has("PIPELINING") + ep.Has8BITMIME = idx.has("8BITMIME") + ep.AUTHPreTLS = idx.parseAuth() + + if ep.STARTTLSOffered { + c, _, _, err := sc.cmd("STARTTLS") + if err == nil && c == 220 { + tlsConn := tls.Client(conn, &tls.Config{ServerName: "fake.test", InsecureSkipVerify: true}) + _ = tlsConn.SetDeadline(time.Now().Add(in.timeout)) + if err := tlsConn.Handshake(); err != nil { + ep.Error = "handshake: " + err.Error() + return ep + } + ep.STARTTLSUpgraded = true + ep.TLSVersion = tls.VersionName(tlsConn.ConnectionState().Version) + sc.swap(tlsConn) + _, _, _, _ = sc.cmd("EHLO " + in.heloName) + } + } + + if in.testNull { + _, _, _, _ = sc.cmd("MAIL FROM:<>") + c, _, _, _ := sc.cmd("RCPT TO:") + ok := c >= 200 && c < 300 + ep.NullSenderAccepted = &ok + } + + sc.close() + return ep +} + +func TestProbe_HappySTARTTLS(t *testing.T) { + s := newFakeSMTPServer(t) + defer s.stop() + s.start() + + ep := s.runProbe(t, probeInputs{domain: "example.com", testNull: true}) + if !ep.TCPConnected || !ep.BannerReceived || ep.BannerCode != 220 { + t.Fatalf("banner: %+v", ep) + } + if !ep.EHLOReceived || !ep.STARTTLSOffered || !ep.STARTTLSUpgraded { + t.Errorf("expected STARTTLS upgrade, got %+v", ep) + } + if !ep.HasPipelining || !ep.Has8BITMIME { + t.Errorf("extension flags: %+v", ep) + } + if ep.NullSenderAccepted == nil || !*ep.NullSenderAccepted { + t.Errorf("null sender: %+v", ep.NullSenderAccepted) + } +} + +func TestProbe_NoSTARTTLS(t *testing.T) { + s := newFakeSMTPServer(t) + s.offerSTARTTLS = false + defer s.stop() + s.start() + + ep := s.runProbe(t, probeInputs{domain: "example.com"}) + if ep.STARTTLSOffered || ep.STARTTLSUpgraded { + t.Errorf("expected no STARTTLS, got %+v", ep) + } +} + +func TestProbe_AUTHBeforeTLS(t *testing.T) { + s := newFakeSMTPServer(t) + s.offerSTARTTLS = false + s.authPreTLS = true + defer s.stop() + s.start() + + ep := s.runProbe(t, probeInputs{domain: "example.com"}) + if len(ep.AUTHPreTLS) == 0 { + t.Errorf("expected AUTH pre-TLS, got %+v", ep) + } +} + +func TestProbe_NoBanner(t *testing.T) { + s := newFakeSMTPServer(t) + s.noBanner = true + defer s.stop() + s.start() + + ep := s.runProbe(t, probeInputs{domain: "example.com", timeout: 500 * time.Millisecond}) + if ep.BannerReceived { + t.Errorf("expected no banner, got %+v", ep) + } + if !strings.HasPrefix(ep.Error, "banner:") { + t.Errorf("error should mention banner, got %q", ep.Error) + } +} + +func TestProbe_RejectsEHLO(t *testing.T) { + s := newFakeSMTPServer(t) + s.rejectEHLO = true + defer s.stop() + s.start() + + ep := s.runProbe(t, probeInputs{domain: "example.com"}) + if ep.EHLOReceived { + t.Errorf("expected EHLO rejection, got %+v", ep) + } +} + +func TestProbe_TCPRefused(t *testing.T) { + // Pick an address nobody listens on. Using port 1 is the most + // portable: privileged + unbound on the loopback interface. + ep := probeAt(t, "127.0.0.1", 1, probeInputs{ + target: "x", ip: "127.0.0.1", domain: "example.com", timeout: 500 * time.Millisecond, + }) + if ep.TCPConnected { + t.Errorf("expected TCP failure, got %+v", ep) + } + if !strings.HasPrefix(ep.Error, "tcp:") { + t.Errorf("error: %q", ep.Error) + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..1662343 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,72 @@ +package checker + +import ( + "net" + "strconv" + + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +func Provider() sdk.ObservationProvider { + return &smtpProvider{} +} + +type smtpProvider struct{} + +func (p *smtpProvider) Key() sdk.ObservationKey { + return ObservationKeySMTP +} + +// DiscoverEntries implements sdk.DiscoveryPublisher. +// +// We publish one tls.endpoint.v1 entry per MX target so the TLS checker +// picks up the connection and runs the cert/chain/SAN/expiry posture +// against it. STARTTLS is "smtp" and RequireSTARTTLS stays false because +// MX SMTP on port 25 is opportunistic (RFC 7672 / RFC 8461 are what turn +// it into a hard requirement, and they live in separate checkers). +// +// SNI is the MX target hostname: that is the name the receiving MTA +// controls and will typically present in its certificate. RFC 7672 +// DANE-TLSA binds the TLSA record to <_port._tcp.mx-target>, so the +// target's A/AAAA+name are also the right reference for DANE. +func (p *smtpProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + d, ok := data.(*SMTPData) + if !ok || d == nil { + return nil, nil + } + if d.MX.NullMX { + return nil, nil + } + + var out []sdk.DiscoveryEntry + seen := map[string]bool{} + for _, rec := range d.MX.Records { + if rec.Target == "" || rec.IsIPLiteral { + continue + } + key := endpointKey(rec.Target, smtpPort) + if seen[key] { + continue + } + seen[key] = true + + ep := tlsct.TLSEndpoint{ + Host: rec.Target, + Port: smtpPort, + SNI: rec.Target, + STARTTLS: "smtp", + RequireSTARTTLS: false, + } + entry, err := tlsct.NewEntry(ep) + if err != nil { + return nil, err + } + out = append(out, entry) + } + return out, nil +} + +func endpointKey(host string, port uint16) string { + return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) +} diff --git a/checker/provider_test.go b/checker/provider_test.go new file mode 100644 index 0000000..62daf18 --- /dev/null +++ b/checker/provider_test.go @@ -0,0 +1,101 @@ +package checker + +import ( + "testing" + + tlsct "git.happydns.org/checker-tls/contract" +) + +func TestProviderKey(t *testing.T) { + if (&smtpProvider{}).Key() != ObservationKeySMTP { + t.Error("Key mismatch") + } +} + +func TestEndpointKey(t *testing.T) { + if got := endpointKey("mx.example.com", 25); got != "mx.example.com:25" { + t.Errorf("got %q", got) + } +} + +func TestDiscoverEntries_NilOrWrongType(t *testing.T) { + p := &smtpProvider{} + if out, _ := p.DiscoverEntries(nil); out != nil { + t.Errorf("nil → %+v", out) + } + if out, _ := p.DiscoverEntries("not-smtp-data"); out != nil { + t.Errorf("wrong type → %+v", out) + } +} + +func TestDiscoverEntries_NullMX(t *testing.T) { + d := &SMTPData{MX: MXLookup{NullMX: true, Records: []MXRecord{{Target: "."}}}} + out, err := (&smtpProvider{}).DiscoverEntries(d) + if err != nil || out != nil { + t.Errorf("null MX should publish nothing, got %+v err=%v", out, err) + } +} + +func TestDiscoverEntries_DedupesAndSkipsIPLiterals(t *testing.T) { + d := &SMTPData{MX: MXLookup{Records: []MXRecord{ + {Target: "mx1.example.com"}, + {Target: "mx1.example.com"}, // duplicate + {Target: "192.0.2.1", IsIPLiteral: true}, + {Target: ""}, // skipped + {Target: "mx2.example.com"}, + }}} + out, err := (&smtpProvider{}).DiscoverEntries(d) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(out) != 2 { + t.Fatalf("want 2 entries, got %d", len(out)) + } + for _, e := range out { + ep, err := tlsct.ParseEntry(e) + if err != nil { + t.Fatalf("parse entry: %v", err) + } + if ep.Port != smtpPort { + t.Errorf("port: got %d", ep.Port) + } + if ep.STARTTLS != "smtp" { + t.Errorf("starttls: got %q", ep.STARTTLS) + } + if ep.RequireSTARTTLS { + t.Errorf("RequireSTARTTLS should be false (opportunistic)") + } + if ep.SNI != ep.Host { + t.Errorf("SNI should default to host: %q vs %q", ep.SNI, ep.Host) + } + } +} + +func TestDefinition(t *testing.T) { + def := (&smtpProvider{}).Definition() + if def.ID != "smtp" { + t.Errorf("ID: %q", def.ID) + } + if !def.HasHTMLReport { + t.Error("expected HasHTMLReport") + } + if len(def.Rules) == 0 { + t.Error("expected rules") + } + if len(def.ObservationKeys) == 0 || def.ObservationKeys[0] != ObservationKeySMTP { + t.Errorf("ObservationKeys: %+v", def.ObservationKeys) + } + // Required option "domain" must be present. + var sawDomain bool + for _, o := range def.Options.RunOpts { + if o.Id == "domain" && o.Required { + sawDomain = true + } + } + if !sawDomain { + t.Error("expected required 'domain' option in RunOpts") + } + if def.Interval == nil || def.Interval.Default == 0 { + t.Error("expected Interval.Default") + } +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..61b1284 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,663 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type reportFix struct { + Severity string + Code string + Message string + Fix string + Endpoint string + Target string +} + +type reportMX struct { + Preference uint16 + Target string + IPv4 []string + IPv6 []string + IsCNAME bool + CNAMEChain []string + IsIPLiteral bool + ResolveErr string +} + +type reportEndpoint struct { + Target string + Address string + IP string + IsIPv6 bool + StatusLabel string + StatusClass string + AnyFail bool + TCPConnected bool + BannerLine string + BannerHostname string + BannerCode int + EHLOReceived bool + EHLOFallbackHELO bool + EHLOHostname string + STARTTLSOffered bool + STARTTLSUpgraded bool + TLSVersion string + TLSCipher string + SizeLimit uint64 + HasPipelining bool + Has8BITMIME bool + HasSMTPUTF8 bool + HasCHUNKING bool + HasDSN bool + HasENHANCEDCODE bool + AUTHPreTLS []string + AUTHPostTLS []string + PTR string + PTRError string + FCrDNSPass bool + NullSenderState string + NullSenderClass string + NullSenderResponse string + PostmasterState string + PostmasterClass string + PostmasterResponse string + OpenRelayState string + OpenRelayClass string + OpenRelayResponse string + OpenRelayRecipient string + ElapsedMS int64 + Error string + + // TLS posture (from a related tls_probes observation, when available). + TLSPosture *reportTLSPosture +} + +type reportTLSPosture struct { + CheckedAt time.Time + ChainValid *bool + HostnameMatch *bool + NotAfter time.Time + Issues []reportFix +} + +type reportData struct { + Domain string + RunAt string + StatusLabel string + StatusClass string + HasIssues bool + Fixes []reportFix + MX []reportMX + NullMX bool + ImplicitMX bool + MXError string + Endpoints []reportEndpoint + HasIPv4 bool + HasIPv6 bool + AnySTARTTLS bool + AllSTARTTLS bool + HasTLSPosture bool +} + +var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{ + "deref": func(b *bool) bool { return b != nil && *b }, + "humanBytes": func(n uint64) string { + if n == 0 { + return "no limit" + } + units := []string{"B", "KiB", "MiB", "GiB", "TiB"} + f := float64(n) + u := 0 + for f >= 1024 && u < len(units)-1 { + f /= 1024 + u++ + } + return fmt.Sprintf("%.1f %s", f, units[u]) + }, +}).Parse(` + + + + +SMTP Report: {{.Domain}} + + + + +
+

SMTP: {{.Domain}}

+ {{.StatusLabel}} +
+ {{if .NullMX}}null MX (refuses mail){{else}} + {{if .AllSTARTTLS}}all STARTTLS + {{else if .AnySTARTTLS}}partial STARTTLS + {{else}}no STARTTLS{{end}} + {{if .HasIPv4}}IPv4{{end}} + {{if .HasIPv6}}IPv6{{end}} + {{end}} +
+
Checked {{.RunAt}}
+
+ +{{if .HasIssues}} +
+

What to fix

+ {{range .Fixes}} +
+
{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+{{end}} + +
+

DNS / MX

+ {{if .NullMX}} +

This domain publishes a null MX record: it explicitly does not accept email (RFC 7505).

+ {{else if .ImplicitMX}} +

No MX record is published; senders will fall back to the domain's A/AAAA (implicit MX, discouraged).

+ {{else if .MXError}} +

MX lookup failed: {{.MXError}}

+ {{else if .MX}} + + + {{range .MX}} + + + + + + + + {{end}} +
PrefTargetIPv4IPv6Issues
{{.Preference}}{{.Target}}{{range .IPv4}}{{.}} {{end}}{{range .IPv6}}{{.}} {{end}} + {{if .IsIPLiteral}}IP literal{{end}} + {{if .IsCNAME}}CNAME chain: {{range .CNAMEChain}}{{.}} {{end}}{{end}} + {{if .ResolveErr}}resolve: {{.ResolveErr}}{{end}} +
+ {{else}} +

No MX records found.

+ {{end}} +
+ +{{if .Endpoints}} +
+

Endpoints ({{len .Endpoints}})

+ {{range .Endpoints}} + + + {{.Target}} · {{.Address}} + {{.StatusLabel}} + +
+
+
Family
{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
+
TCP :25
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
+ {{if .BannerLine}} +
Banner
+ + {{if .BannerHostname}}
announced name: {{.BannerHostname}}
{{end}} +
+ {{end}} +
EHLO
+ {{if .EHLOFallbackHELO}}✗ EHLO rejected, only HELO works + {{else if .EHLOReceived}}✓ accepted{{if .EHLOHostname}} ({{.EHLOHostname}}){{end}} + {{else}}✗ failed{{end}} +
+ {{if .EHLOReceived}} +
Extensions
+
+ {{if .STARTTLSOffered}}STARTTLS{{else}}no STARTTLS{{end}} + {{if .HasPipelining}}PIPELINING{{else}}no PIPELINING{{end}} + {{if .Has8BITMIME}}8BITMIME{{end}} + {{if .HasSMTPUTF8}}SMTPUTF8{{end}} + {{if .HasCHUNKING}}CHUNKING{{end}} + {{if .HasDSN}}DSN{{end}} + {{if .HasENHANCEDCODE}}ENHANCEDSTATUSCODES{{end}} + {{if .SizeLimit}}SIZE {{humanBytes .SizeLimit}}{{end}} +
+
+ {{end}} + {{if .AUTHPreTLS}} +
AUTH pre-TLS
+ ✗ advertised without TLS: + {{range .AUTHPreTLS}}{{.}} {{end}} +
+ {{end}} + {{if .AUTHPostTLS}} +
AUTH post-TLS
{{range .AUTHPostTLS}}{{.}} {{end}}
+ {{end}} +
STARTTLS
+ {{if .STARTTLSUpgraded}}✓ {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}} + {{else if .STARTTLSOffered}}✗ handshake failed + {{else}}✗ not offered{{end}} +
+ {{with .TLSPosture}} +
TLS cert
+ {{if .ChainValid}}{{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}}{{end}} + {{if .HostnameMatch}} · {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}}{{end}} + {{if not .NotAfter.IsZero}} · expires {{.NotAfter.Format "2006-01-02"}}{{end}} + {{if not .CheckedAt.IsZero}}
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
{{end}} + {{range .Issues}} +
+
{{.Code}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+ {{end}} +
PTR
+ {{if .PTR}}{{.PTR}} + {{if .FCrDNSPass}}· ✓ FCrDNS + {{else}}· ✗ FCrDNS mismatch{{end}} + {{else}}✗ no PTR{{if .PTRError}} ({{.PTRError}}){{end}}{{end}} +
+ {{if .NullSenderState}} +
Null sender
+ {{.NullSenderState}} +
{{.NullSenderResponse}}
+
+ {{end}} + {{if .PostmasterState}} +
Postmaster
+ {{.PostmasterState}} +
{{.PostmasterResponse}}
+
+ {{end}} + {{if .OpenRelayState}} +
Open relay
+ {{.OpenRelayState}} +
rcpt={{.OpenRelayRecipient}}: {{.OpenRelayResponse}}
+
+ {{end}} +
Duration
{{.ElapsedMS}} ms
+ {{if .Error}}
Error
{{.Error}}
{{end}} +
+
+ + {{end}} +
+{{end}} + + + + +`)) + +// GetHTMLReport implements sdk.CheckerHTMLReporter. +func (p *smtpProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { + var d SMTPData + if err := json.Unmarshal(rctx.Data(), &d); err != nil { + return "", fmt.Errorf("unmarshal smtp observation: %w", err) + } + view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) + return renderReport(view) +} + +func renderReport(view reportData) (string, error) { + var buf strings.Builder + if err := reportTpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("render smtp report: %w", err) + } + return buf.String(), nil +} + +func buildReportData(d *SMTPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { + tlsByAddr := indexTLSByAddress(related) + + fixes := fixesFromStates(states) + + view := reportData{ + Domain: d.Domain, + RunAt: d.RunAt, + NullMX: d.MX.NullMX, + ImplicitMX: d.MX.ImplicitMX, + MXError: d.MX.Error, + HasIPv4: d.Coverage.HasIPv4, + HasIPv6: d.Coverage.HasIPv6, + AnySTARTTLS: d.Coverage.AnySTARTTLS, + AllSTARTTLS: d.Coverage.AllSTARTTLS, + HasIssues: len(fixes) > 0, + HasTLSPosture: len(tlsByAddr) > 0, + } + + view.StatusLabel, view.StatusClass = overallStatus(d, states, fixes) + + sevRank := func(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } + } + sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) + view.Fixes = fixes + + for _, rec := range d.MX.Records { + view.MX = append(view.MX, reportMX{ + Preference: rec.Preference, + Target: rec.Target, + IPv4: rec.IPv4, + IPv6: rec.IPv6, + IsCNAME: rec.IsCNAME, + CNAMEChain: rec.CNAMEChain, + IsIPLiteral: rec.IsIPLiteral, + ResolveErr: rec.ResolveError, + }) + } + + for _, ep := range d.Endpoints { + re := reportEndpoint{ + Target: ep.Target, + Address: ep.Address, + IP: ep.IP, + IsIPv6: ep.IsIPv6, + TCPConnected: ep.TCPConnected, + BannerLine: ep.BannerLine, + BannerHostname: ep.BannerHostname, + BannerCode: ep.BannerCode, + EHLOReceived: ep.EHLOReceived, + EHLOFallbackHELO: ep.EHLOFallbackHELO, + EHLOHostname: ep.EHLOHostname, + STARTTLSOffered: ep.STARTTLSOffered, + STARTTLSUpgraded: ep.STARTTLSUpgraded, + TLSVersion: ep.TLSVersion, + TLSCipher: ep.TLSCipher, + SizeLimit: ep.SizeLimit, + HasPipelining: ep.HasPipelining, + Has8BITMIME: ep.Has8BITMIME, + HasSMTPUTF8: ep.HasSMTPUTF8, + HasCHUNKING: ep.HasCHUNKING, + HasDSN: ep.HasDSN, + HasENHANCEDCODE: ep.HasENHANCEDCODE, + AUTHPreTLS: ep.AUTHPreTLS, + AUTHPostTLS: ep.AUTHPostTLS, + PTR: ep.PTR, + PTRError: ep.PTRError, + FCrDNSPass: ep.FCrDNSPass, + NullSenderResponse: ep.NullSenderResponse, + PostmasterResponse: ep.PostmasterResponse, + OpenRelayResponse: ep.OpenRelayResponse, + OpenRelayRecipient: ep.OpenRelayRecipient, + ElapsedMS: ep.ElapsedMS, + Error: ep.Error, + } + if ep.NullSenderAccepted != nil { + if *ep.NullSenderAccepted { + re.NullSenderState = "accepted" + re.NullSenderClass = "ok" + } else { + re.NullSenderState = "REJECTED" + re.NullSenderClass = "fail" + } + } + if ep.PostmasterAccepted != nil { + if *ep.PostmasterAccepted { + re.PostmasterState = "accepted" + re.PostmasterClass = "ok" + } else { + re.PostmasterState = "REJECTED" + re.PostmasterClass = "fail" + } + } + if ep.OpenRelay != nil { + if *ep.OpenRelay { + re.OpenRelayState = "OPEN RELAY" + re.OpenRelayClass = "fail" + } else { + re.OpenRelayState = "properly refused" + re.OpenRelayClass = "ok" + } + } + if meta, hit := tlsByAddr[ep.Address]; hit { + re.TLSPosture = meta + } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { + re.TLSPosture = meta + } + ok := ep.TCPConnected && ep.EHLOReceived + if ep.STARTTLSOffered { + ok = ok && ep.STARTTLSUpgraded + } + if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted { + ok = false + } + if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted { + ok = false + } + if ep.OpenRelay != nil && *ep.OpenRelay { + ok = false + } + re.AnyFail = !ok + switch { + case !ep.TCPConnected: + re.StatusLabel = "unreachable" + re.StatusClass = "fail" + case ep.OpenRelay != nil && *ep.OpenRelay: + re.StatusLabel = "OPEN RELAY" + re.StatusClass = "fail" + case !ok: + re.StatusLabel = "partial" + re.StatusClass = "warn" + default: + re.StatusLabel = "OK" + re.StatusClass = "ok" + } + view.Endpoints = append(view.Endpoints, re) + } + + return view +} + +// fixesFromStates turns the rule-driven CheckStates into the hint/fix +// entries the report renders. It consumes Message, Meta["fix"], and Status +// exclusively, the derivation of those fields lives in the rules, not +// here. States that do not represent a finding (OK, Unknown) are skipped. +func fixesFromStates(states []sdk.CheckState) []reportFix { + out := make([]reportFix, 0, len(states)) + for _, st := range states { + sev := statusToSeverity(st.Status) + if sev == "" { + continue + } + fix := "" + endpoint := "" + target := "" + if st.Meta != nil { + if s, ok := st.Meta["fix"].(string); ok { + fix = s + } + if s, ok := st.Meta["endpoint"].(string); ok { + endpoint = s + } + if s, ok := st.Meta["target"].(string); ok { + target = s + } + } + out = append(out, reportFix{ + Severity: sev, + Code: st.Code, + Message: st.Message, + Fix: fix, + Endpoint: endpoint, + Target: target, + }) + } + return out +} + +// statusToSeverity maps an sdk.Status to the severity strings used by the +// HTML template. Status values that represent a non-finding (OK, Unknown) +// return "" so the caller can skip them. +func statusToSeverity(s sdk.Status) string { + switch s { + case sdk.StatusCrit, sdk.StatusError: + return SeverityCrit + case sdk.StatusWarn: + return SeverityWarn + case sdk.StatusInfo: + return SeverityInfo + default: + return "" + } +} + +// overallStatus picks the overall badge label/class. When there are no +// states at all (data-only render), we fall back to a neutral "data only" +// badge instead of claiming "OK", we can't assert anything we haven't +// actually evaluated. +func overallStatus(d *SMTPData, states []sdk.CheckState, fixes []reportFix) (string, string) { + if d.MX.NullMX { + return "NULL MX", "info" + } + if len(states) == 0 { + return "data only", "muted" + } + worst := "" + for _, f := range fixes { + if f.Severity == SeverityCrit { + worst = SeverityCrit + break + } + if f.Severity == SeverityWarn { + worst = SeverityWarn + } else if worst == "" && f.Severity == SeverityInfo { + worst = SeverityInfo + } + } + switch worst { + case SeverityCrit: + return "FAIL", "fail" + case SeverityWarn: + return "WARN", "warn" + case SeverityInfo: + return "INFO", "info" + default: + return "OK", "ok" + } +} + +func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { + out := map[string]*reportTLSPosture{} + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if addr == "" { + continue + } + posture := &reportTLSPosture{ + CheckedAt: r.CollectedAt, + ChainValid: v.ChainValid, + HostnameMatch: v.HostnameMatch, + NotAfter: v.NotAfter, + } + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { + continue + } + posture.Issues = append(posture.Issues, reportFix{ + Severity: sev, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + }) + } + out[addr] = posture + } + return out +} diff --git a/checker/report_test.go b/checker/report_test.go new file mode 100644 index 0000000..0232352 --- /dev/null +++ b/checker/report_test.go @@ -0,0 +1,240 @@ +package checker + +import ( + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestStatusToSeverity(t *testing.T) { + cases := []struct { + in sdk.Status + want string + }{ + {sdk.StatusCrit, SeverityCrit}, + {sdk.StatusError, SeverityCrit}, + {sdk.StatusWarn, SeverityWarn}, + {sdk.StatusInfo, SeverityInfo}, + {sdk.StatusOK, ""}, + {sdk.StatusUnknown, ""}, + } + for _, c := range cases { + if got := statusToSeverity(c.in); got != c.want { + t.Errorf("status %v: want %q, got %q", c.in, c.want, got) + } + } +} + +func TestOverallStatus_NullMX(t *testing.T) { + d := &SMTPData{MX: MXLookup{NullMX: true}} + label, class := overallStatus(d, nil, nil) + if label != "NULL MX" || class != "info" { + t.Errorf("got (%q,%q)", label, class) + } +} + +func TestOverallStatus_DataOnly(t *testing.T) { + d := &SMTPData{} + label, class := overallStatus(d, nil, nil) + if label != "data only" || class != "muted" { + t.Errorf("got (%q,%q)", label, class) + } +} + +func TestOverallStatus_FromFixes(t *testing.T) { + d := &SMTPData{} + states := []sdk.CheckState{{Status: sdk.StatusOK}} + cases := []struct { + fixes []reportFix + wantLabel string + wantClass string + caseLabel string + }{ + {[]reportFix{{Severity: SeverityCrit}}, "FAIL", "fail", "crit"}, + {[]reportFix{{Severity: SeverityWarn}}, "WARN", "warn", "warn"}, + {[]reportFix{{Severity: SeverityInfo}}, "INFO", "info", "info"}, + {nil, "OK", "ok", "ok"}, + } + for _, c := range cases { + label, class := overallStatus(d, states, c.fixes) + if label != c.wantLabel || class != c.wantClass { + t.Errorf("%s: got (%q,%q)", c.caseLabel, label, class) + } + } +} + +func TestOverallStatus_CritWinsOverWarn(t *testing.T) { + d := &SMTPData{} + states := []sdk.CheckState{{Status: sdk.StatusOK}} + fixes := []reportFix{{Severity: SeverityWarn}, {Severity: SeverityCrit}, {Severity: SeverityInfo}} + if label, _ := overallStatus(d, states, fixes); label != "FAIL" { + t.Errorf("crit must dominate, got %q", label) + } +} + +func TestFixesFromStates_OnlyFindings(t *testing.T) { + states := []sdk.CheckState{ + {Status: sdk.StatusOK, Code: "skip-me"}, + {Status: sdk.StatusUnknown, Code: "skip-me-too"}, + {Status: sdk.StatusWarn, Code: "warn-1", Message: "msg", Meta: map[string]any{"fix": "do x", "endpoint": "1.2.3.4:25", "target": "mx"}}, + {Status: sdk.StatusCrit, Code: "crit-1", Message: "boom"}, + } + out := fixesFromStates(states) + if len(out) != 2 { + t.Fatalf("want 2 fixes, got %d", len(out)) + } + w := out[0] + if w.Severity != SeverityWarn || w.Fix != "do x" || w.Endpoint != "1.2.3.4:25" || w.Target != "mx" { + t.Errorf("warn fix wrong: %+v", w) + } +} + +func TestFixesFromStates_MetaWrongTypesIgnored(t *testing.T) { + states := []sdk.CheckState{ + {Status: sdk.StatusWarn, Code: "x", Meta: map[string]any{"fix": 42, "endpoint": nil}}, + } + out := fixesFromStates(states) + if len(out) != 1 { + t.Fatalf("got %d", len(out)) + } + if out[0].Fix != "" || out[0].Endpoint != "" { + t.Errorf("non-string meta values must be ignored, got %+v", out[0]) + } +} + +func TestIndexTLSByAddress(t *testing.T) { + yes := true + notAfter := time.Now().Add(30 * 24 * time.Hour) + payload := map[string]any{ + "host": "mx.example.com", "port": 25, + "chain_valid": yes, "hostname_match": yes, "not_after": notAfter, + "issues": []map[string]any{ + {"code": "x", "severity": "warn", "message": "m"}, + {"code": "y", "severity": "bogus"}, // dropped + }, + } + related := []sdk.RelatedObservation{{Data: mustJSON(t, payload), CollectedAt: time.Now()}} + idx := indexTLSByAddress(related) + posture, ok := idx["mx.example.com:25"] + if !ok { + t.Fatalf("expected entry, got %+v", idx) + } + if posture.ChainValid == nil || !*posture.ChainValid { + t.Errorf("ChainValid: %+v", posture.ChainValid) + } + if len(posture.Issues) != 1 { + t.Errorf("issues: want 1, got %d", len(posture.Issues)) + } +} + +func TestBuildReportData_StatusByEndpoint(t *testing.T) { + yes := true + relay := true + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{Records: []MXRecord{{Preference: 10, Target: "mx.example.com", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{ + // healthy + { + Target: "mx.example.com", IP: "1.2.3.4", Port: 25, Address: "1.2.3.4:25", + TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + NullSenderAccepted: &yes, PostmasterAccepted: &yes, + }, + // unreachable + {Target: "mx.example.com", IP: "1.2.3.5", Port: 25, Address: "1.2.3.5:25"}, + // open relay + { + Target: "mx.example.com", IP: "1.2.3.6", Port: 25, Address: "1.2.3.6:25", + TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, + STARTTLSOffered: true, STARTTLSUpgraded: true, + OpenRelay: &relay, + }, + }, + } + view := buildReportData(d, nil, []sdk.CheckState{{Status: sdk.StatusOK}}) + if len(view.Endpoints) != 3 { + t.Fatalf("want 3 endpoints, got %d", len(view.Endpoints)) + } + wantStatuses := []string{"OK", "unreachable", "OPEN RELAY"} + for i, want := range wantStatuses { + if view.Endpoints[i].StatusLabel != want { + t.Errorf("endpoint[%d]: got %q, want %q", i, view.Endpoints[i].StatusLabel, want) + } + } +} + +func TestBuildReportData_FixesSortedBySeverity(t *testing.T) { + d := &SMTPData{Domain: "x"} + states := []sdk.CheckState{ + {Status: sdk.StatusInfo, Code: "info-1"}, + {Status: sdk.StatusCrit, Code: "crit-1"}, + {Status: sdk.StatusWarn, Code: "warn-1"}, + } + view := buildReportData(d, nil, states) + if len(view.Fixes) != 3 { + t.Fatalf("got %d fixes", len(view.Fixes)) + } + if view.Fixes[0].Severity != SeverityCrit || + view.Fixes[1].Severity != SeverityWarn || + view.Fixes[2].Severity != SeverityInfo { + t.Errorf("not sorted: %+v", view.Fixes) + } +} + +func TestRenderReport_ContainsDomain(t *testing.T) { + view := reportData{ + Domain: "example.com", + StatusLabel: "OK", + StatusClass: "ok", + } + html, err := renderReport(view) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(html, "example.com") { + t.Errorf("html missing domain") + } + if !strings.Contains(html, "") { + t.Errorf("not an html doc") + } +} + +func TestGetHTMLReport_RoundTrip(t *testing.T) { + yes := true + d := &SMTPData{ + Domain: "example.com", + RunAt: "2026-01-01T00:00:00Z", + MX: MXLookup{Records: []MXRecord{{Preference: 10, Target: "mx.example.com", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{{ + Target: "mx.example.com", IP: "1.2.3.4", Port: 25, Address: "1.2.3.4:25", + TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + NullSenderAccepted: &yes, PostmasterAccepted: &yes, + }}, + } + body, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + rctx := sdk.StaticReportContext(body) + p := &smtpProvider{} + html, err := p.GetHTMLReport(rctx) + if err != nil { + t.Fatalf("GetHTMLReport: %v", err) + } + if !strings.Contains(html, "mx.example.com") { + t.Errorf("html missing target hostname") + } +} + +func TestGetHTMLReport_BadJSON(t *testing.T) { + rctx := sdk.StaticReportContext(json.RawMessage("{not json")) + p := &smtpProvider{} + if _, err := p.GetHTMLReport(rctx); err == nil { + t.Fatal("expected error on bad json") + } +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..cc05fe3 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,214 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules returns the full list of CheckRules exposed by the SMTP checker. +// Each rule covers a single concern (MX present, STARTTLS offered, open +// relay, PTR/FCrDNS, …) so each shows up as an independent pass/fail line +// in the UI, instead of being buried under a single monolithic rule. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &nullMXRule{}, + &simpleConcernRule{ + name: "smtp.mx_present", + description: "Verifies the domain publishes at least one MX record (or a null MX).", + codes: []string{CodeMXLookupFailed, CodeImplicitMX, CodeNoMX}, + passCode: "smtp.mx_present.ok", + passMessage: "Domain publishes explicit MX records.", + }, + &simpleConcernRule{ + name: "smtp.mx_sanity", + description: "Flags MX targets that violate RFC 5321 § 5.1 (IP literals, CNAME chains, unresolved names).", + codes: []string{CodeMXIPLiteral, CodeMXCNAME, CodeMXResolveFailed, CodeNoAddresses}, + passCode: "smtp.mx_sanity.ok", + passMessage: "MX targets resolve cleanly and are regular hostnames.", + }, + &simpleConcernRule{ + name: "smtp.endpoint_reachable", + description: "Verifies every MX endpoint accepts a TCP connection on port 25.", + codes: []string{CodeTCPUnreachable, CodeAllEndpointsDown}, + passCode: "smtp.endpoint_reachable.ok", + passMessage: "All MX endpoints are reachable on port 25.", + }, + &simpleConcernRule{ + name: "smtp.banner_sanity", + description: "Verifies every reachable endpoint emits a 220 SMTP greeting.", + codes: []string{CodeBannerMissing, CodeBannerInvalid}, + passCode: "smtp.banner_sanity.ok", + passMessage: "Every reachable endpoint presents a valid 220 banner.", + }, + &simpleConcernRule{ + name: "smtp.ehlo_supported", + description: "Verifies every endpoint accepts EHLO (required for STARTTLS, PIPELINING, SIZE, …).", + codes: []string{CodeEHLOFailed, CodeEHLOFallback}, + passCode: "smtp.ehlo_supported.ok", + passMessage: "Every endpoint accepts EHLO.", + }, + &simpleConcernRule{ + name: "smtp.starttls_offered", + description: "Verifies every endpoint advertises the STARTTLS extension.", + codes: []string{CodeSTARTTLSMissing, CodeAllNoSTARTTLS}, + passCode: "smtp.starttls_offered.ok", + passMessage: "Every endpoint advertises STARTTLS.", + }, + &simpleConcernRule{ + name: "smtp.starttls_handshake", + description: "Verifies the STARTTLS handshake succeeds wherever STARTTLS is advertised.", + codes: []string{CodeSTARTTLSFailed}, + passCode: "smtp.starttls_handshake.ok", + passMessage: "STARTTLS handshake succeeds on every endpoint that offers it.", + }, + &simpleConcernRule{ + name: "smtp.auth_posture", + description: "Flags endpoints that advertise SMTP AUTH before STARTTLS (cleartext credentials).", + codes: []string{CodeAUTHOverPlain}, + passCode: "smtp.auth_posture.ok", + passMessage: "No endpoint advertises SMTP AUTH in cleartext.", + }, + &simpleConcernRule{ + name: "smtp.reverse_dns", + description: "Verifies every endpoint has a matching PTR record (FCrDNS).", + codes: []string{CodePTRMissing, CodeFCrDNSMismatch}, + passCode: "smtp.reverse_dns.ok", + passMessage: "Every endpoint has a PTR record that forward-confirms.", + }, + &simpleConcernRule{ + name: "smtp.null_sender", + description: "Verifies endpoints accept the null sender MAIL FROM:<> (required for DSNs).", + codes: []string{CodeNullSenderReject}, + passCode: "smtp.null_sender.ok", + passMessage: "Endpoints accept the RFC 5321 null sender.", + }, + &simpleConcernRule{ + name: "smtp.postmaster", + description: "Verifies endpoints accept RCPT TO: (RFC 5321 § 4.5.1).", + codes: []string{CodePostmasterReject}, + passCode: "smtp.postmaster.ok", + passMessage: "Endpoints accept mail for .", + }, + &simpleConcernRule{ + name: "smtp.open_relay", + description: "Flags endpoints that relay mail for recipients outside the tested domain.", + codes: []string{CodeOpenRelay}, + passCode: "smtp.open_relay.ok", + passMessage: "No endpoint accepts relay for foreign recipients.", + }, + &simpleConcernRule{ + name: "smtp.extension_posture", + description: "Reports ESMTP extension posture (PIPELINING, 8BITMIME).", + codes: []string{CodeNoPipelining, CodeNo8BITMIME}, + passCode: "smtp.extension_posture.ok", + passMessage: "Endpoints advertise the common ESMTP extensions.", + }, + &simpleConcernRule{ + name: "smtp.ipv6_reachable", + description: "Verifies at least one MX endpoint is reachable over IPv6.", + codes: []string{CodeNoIPv6}, + passCode: "smtp.ipv6_reachable.ok", + passMessage: "At least one MX endpoint is reachable over IPv6.", + }, + &tlsQualityRule{}, + } +} + +// loadSMTPData fetches the SMTP observation. On error, returns a CheckState +// the caller should emit to short-circuit its rule. +func loadSMTPData(ctx context.Context, obs sdk.ObservationGetter) (*SMTPData, *sdk.CheckState) { + var data SMTPData + if err := obs.Get(ctx, ObservationKeySMTP, &data); err != nil { + return nil, &sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load SMTP observation: %v", err), + Code: "smtp.observation_error", + } + } + return &data, nil +} + +// issuesByCodes returns derived issues whose Code is in the given set, +// preserving the order deriveIssues produces. +func issuesByCodes(data *SMTPData, codes ...string) []Issue { + if len(codes) == 0 { + return nil + } + set := make(map[string]struct{}, len(codes)) + for _, c := range codes { + set[c] = struct{}{} + } + var out []Issue + for _, is := range deriveIssues(data) { + if _, ok := set[is.Code]; ok { + out = append(out, is) + } + } + return out +} + +func statesFromIssues(issues []Issue) []sdk.CheckState { + out := make([]sdk.CheckState, 0, len(issues)) + for _, is := range issues { + out = append(out, issueToState(is)) + } + return out +} + +func issueToState(is Issue) sdk.CheckState { + subject := is.Endpoint + if subject == "" { + subject = is.Target + } + meta := map[string]any{} + if is.Fix != "" { + meta["fix"] = is.Fix + } + if is.Endpoint != "" { + meta["endpoint"] = is.Endpoint + } + if is.Target != "" { + meta["target"] = is.Target + } + st := sdk.CheckState{ + Status: severityToStatus(is.Severity), + Message: is.Message, + Code: is.Code, + Subject: subject, + } + if len(meta) > 0 { + st.Meta = meta + } + return st +} + +func severityToStatus(sev string) sdk.Status { + switch sev { + case SeverityCrit: + return sdk.StatusCrit + case SeverityWarn: + return sdk.StatusWarn + case SeverityInfo: + return sdk.StatusInfo + default: + return sdk.StatusOK + } +} + +func passState(code, message string) sdk.CheckState { + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: message, + Code: code, + } +} + +func notTestedState(code, message string) sdk.CheckState { + return sdk.CheckState{ + Status: sdk.StatusUnknown, + Message: message, + Code: code, + } +} diff --git a/checker/rule_test.go b/checker/rule_test.go new file mode 100644 index 0000000..dc8b8b5 --- /dev/null +++ b/checker/rule_test.go @@ -0,0 +1,294 @@ +package checker + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func mustJSONForRule(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// stubObs is a minimal sdk.ObservationGetter for the rule tests. It is +// keyed by ObservationKey so a single instance can serve a Get and any +// number of GetRelated lookups. +type stubObs struct { + data *SMTPData + getErr error + related map[sdk.ObservationKey][]sdk.RelatedObservation +} + +func (s *stubObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error { + if s.getErr != nil { + return s.getErr + } + if s.data == nil { + return errors.New("no data") + } + b, err := json.Marshal(s.data) + if err != nil { + return err + } + return json.Unmarshal(b, dest) +} + +func (s *stubObs) GetRelated(_ context.Context, key sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return s.related[key], nil +} + +func TestSeverityToStatus(t *testing.T) { + cases := []struct { + sev string + want sdk.Status + }{ + {SeverityCrit, sdk.StatusCrit}, + {SeverityWarn, sdk.StatusWarn}, + {SeverityInfo, sdk.StatusInfo}, + {"", sdk.StatusOK}, + {"bogus", sdk.StatusOK}, + } + for _, c := range cases { + if got := severityToStatus(c.sev); got != c.want { + t.Errorf("%q → %v, want %v", c.sev, got, c.want) + } + } +} + +func TestPassAndNotTestedStates(t *testing.T) { + p := passState("c.ok", "fine") + if p.Status != sdk.StatusOK || p.Code != "c.ok" || p.Message != "fine" { + t.Errorf("passState: %+v", p) + } + n := notTestedState("c.skip", "n/a") + if n.Status != sdk.StatusUnknown || n.Code != "c.skip" { + t.Errorf("notTestedState: %+v", n) + } +} + +func TestIssueToState(t *testing.T) { + is := Issue{ + Code: "x", Severity: SeverityWarn, Message: "m", Fix: "do", + Endpoint: "1.2.3.4:25", Target: "mx", + } + st := issueToState(is) + if st.Status != sdk.StatusWarn { + t.Errorf("status: %v", st.Status) + } + if st.Subject != "1.2.3.4:25" { + t.Errorf("subject (endpoint preferred): %q", st.Subject) + } + if st.Meta["fix"] != "do" || st.Meta["endpoint"] != "1.2.3.4:25" || st.Meta["target"] != "mx" { + t.Errorf("meta: %+v", st.Meta) + } +} + +func TestIssueToState_TargetFallbackSubject(t *testing.T) { + is := Issue{Code: "x", Severity: SeverityCrit, Target: "mx"} + st := issueToState(is) + if st.Subject != "mx" { + t.Errorf("expected target subject, got %q", st.Subject) + } +} + +func TestIssueToState_NoMeta(t *testing.T) { + is := Issue{Code: "x", Severity: SeverityInfo} + st := issueToState(is) + if st.Meta != nil { + t.Errorf("meta should be nil when no fields set, got %+v", st.Meta) + } +} + +func TestStatesFromIssues(t *testing.T) { + issues := []Issue{ + {Code: "a", Severity: SeverityCrit}, + {Code: "b", Severity: SeverityWarn}, + } + states := statesFromIssues(issues) + if len(states) != 2 { + t.Fatalf("got %d", len(states)) + } + if states[0].Code != "a" || states[1].Code != "b" { + t.Errorf("order not preserved: %+v", states) + } +} + +func TestIssuesByCodes_FiltersAndKeepsOrder(t *testing.T) { + d := &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{{Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", Error: "boom"}}, + } + got := issuesByCodes(d, CodeTCPUnreachable, CodeAllEndpointsDown, "smtp.does-not-exist") + if len(got) != 2 { + t.Fatalf("want 2 issues, got %d", len(got)) + } + codes := []string{got[0].Code, got[1].Code} + want := []string{CodeTCPUnreachable, CodeAllEndpointsDown} + if !reflect.DeepEqual(codes, want) { + t.Errorf("order: got %v, want %v", codes, want) + } +} + +func TestIssuesByCodes_EmptyCodes(t *testing.T) { + if got := issuesByCodes(&SMTPData{}); got != nil { + t.Errorf("expected nil, got %+v", got) + } +} + +func TestRules_ContainsAllExpectedNames(t *testing.T) { + rules := Rules() + got := map[string]bool{} + for _, r := range rules { + got[r.Name()] = true + if r.Description() == "" { + t.Errorf("%s: empty description", r.Name()) + } + } + want := []string{ + "smtp.null_mx", "smtp.mx_present", "smtp.mx_sanity", + "smtp.endpoint_reachable", "smtp.banner_sanity", "smtp.ehlo_supported", + "smtp.starttls_offered", "smtp.starttls_handshake", "smtp.auth_posture", + "smtp.reverse_dns", "smtp.null_sender", "smtp.postmaster", + "smtp.open_relay", "smtp.extension_posture", "smtp.ipv6_reachable", + "smtp.tls_quality", + } + for _, n := range want { + if !got[n] { + t.Errorf("missing rule %q", n) + } + } +} + +func TestNullMXRule_Detected(t *testing.T) { + obs := &stubObs{data: &SMTPData{MX: MXLookup{NullMX: true}}} + st := (&nullMXRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusInfo || st[0].Code != CodeNullMX { + t.Errorf("got %+v", st) + } +} + +func TestNullMXRule_NotNull(t *testing.T) { + obs := &stubObs{data: &SMTPData{Domain: "x", MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}}} + st := (&nullMXRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("expected pass, got %+v", st) + } +} + +func TestNullMXRule_LoadError(t *testing.T) { + obs := &stubObs{getErr: errors.New("boom")} + st := (&nullMXRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusError { + t.Errorf("expected error, got %+v", st) + } +} + +func TestSimpleConcernRule_PassWhenNoMatchingIssues(t *testing.T) { + yes := true + obs := &stubObs{data: &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{{Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", TCPConnected: true, BannerReceived: true, BannerCode: 220, EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, NullSenderAccepted: &yes, PostmasterAccepted: &yes, PTR: "mx.x", FCrDNSPass: true, HasPipelining: true, Has8BITMIME: true}}, + }} + r := &simpleConcernRule{name: "smtp.endpoint_reachable", codes: []string{CodeTCPUnreachable}, passCode: "smtp.endpoint_reachable.ok", passMessage: "ok"} + st := r.Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("expected single pass state, got %+v", st) + } +} + +func TestSimpleConcernRule_EmitsMatchingIssues(t *testing.T) { + obs := &stubObs{data: &SMTPData{ + Domain: "x", + MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}, + Endpoints: []EndpointProbe{{Target: "mx.x", IP: "1.2.3.4", Address: "1.2.3.4:25", Error: "boom"}}, + }} + r := &simpleConcernRule{name: "smtp.endpoint_reachable", codes: []string{CodeTCPUnreachable, CodeAllEndpointsDown}, passCode: "smtp.endpoint_reachable.ok", passMessage: "ok"} + st := r.Evaluate(context.Background(), obs, nil) + if len(st) != 2 { + t.Fatalf("want 2 states, got %d (%+v)", len(st), st) + } + if st[0].Status != sdk.StatusCrit { + t.Errorf("expected crit status, got %v", st[0].Status) + } +} + +func TestSimpleConcernRule_NullMXSkipped(t *testing.T) { + obs := &stubObs{data: &SMTPData{MX: MXLookup{NullMX: true}}} + r := &simpleConcernRule{name: "smtp.starttls_offered", codes: []string{CodeSTARTTLSMissing}, passCode: "smtp.starttls_offered.ok"} + st := r.Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("null MX should yield not-tested, got %+v", st) + } +} + +func TestSimpleConcernRule_LoadError(t *testing.T) { + obs := &stubObs{getErr: errors.New("nope")} + r := &simpleConcernRule{name: "smtp.x", codes: []string{CodeTCPUnreachable}, passCode: "ok"} + st := r.Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusError { + t.Errorf("got %+v", st) + } +} + +func TestTLSQualityRule_NoRelated(t *testing.T) { + obs := &stubObs{data: &SMTPData{Domain: "x", MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}}} + st := (&tlsQualityRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("expected not-tested, got %+v", st) + } +} + +func TestTLSQualityRule_NullMXSkipped(t *testing.T) { + obs := &stubObs{data: &SMTPData{MX: MXLookup{NullMX: true}}} + st := (&tlsQualityRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("got %+v", st) + } +} + +func TestTLSQualityRule_PassWhenRelatedClean(t *testing.T) { + yes := true + notAfter := time.Now().Add(365 * 24 * time.Hour) + payload := map[string]any{"host": "mx.x", "port": 25, "chain_valid": yes, "hostname_match": yes, "not_after": notAfter} + related := map[sdk.ObservationKey][]sdk.RelatedObservation{ + TLSRelatedKey: {{Data: mustJSONForRule(t, payload)}}, + } + obs := &stubObs{ + data: &SMTPData{Domain: "x", MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}}, + related: related, + } + st := (&tlsQualityRule{}).Evaluate(context.Background(), obs, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("expected ok pass, got %+v", st) + } +} + +func TestTLSQualityRule_RelatedIssuesFlow(t *testing.T) { + payload := map[string]any{ + "host": "mx.x", "port": 25, + "issues": []map[string]any{{"code": "cert.expired", "severity": "crit", "message": "expired"}}, + } + related := map[sdk.ObservationKey][]sdk.RelatedObservation{ + TLSRelatedKey: {{Data: mustJSONForRule(t, payload)}}, + } + obs := &stubObs{ + data: &SMTPData{Domain: "x", MX: MXLookup{Records: []MXRecord{{Target: "mx.x", IPv4: []string{"1.2.3.4"}}}}}, + related: related, + } + st := (&tlsQualityRule{}).Evaluate(context.Background(), obs, nil) + if len(st) == 0 || st[0].Status != sdk.StatusCrit { + t.Errorf("expected crit, got %+v", st) + } +} diff --git a/checker/rules_null_mx.go b/checker/rules_null_mx.go new file mode 100644 index 0000000..9aad277 --- /dev/null +++ b/checker/rules_null_mx.go @@ -0,0 +1,33 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// nullMXRule reports whether the domain declares a null MX (RFC 7505). +// This is surfaced as a distinct rule so the rest of the rules can short +// out cleanly: every other rule skips when data.MX.NullMX is true. +type nullMXRule struct{} + +func (r *nullMXRule) Name() string { return "smtp.null_mx" } +func (r *nullMXRule) Description() string { + return "Reports whether the domain publishes a null MX (RFC 7505), which declares it does not accept mail." +} + +func (r *nullMXRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadSMTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.MX.NullMX { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: "Domain refuses all email via null MX (RFC 7505).", + Code: CodeNullMX, + Meta: map[string]any{"null_mx": true}, + }} + } + return []sdk.CheckState{passState("smtp.null_mx.ok", "Domain does not declare a null MX.")} +} diff --git a/checker/rules_simple.go b/checker/rules_simple.go new file mode 100644 index 0000000..e6d683a --- /dev/null +++ b/checker/rules_simple.go @@ -0,0 +1,39 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// simpleConcernRule is the common shape for "load SMTPData, derive the +// issues, keep only those whose Code matches one of `codes`, and emit +// either the matching states or a single pass state". +type simpleConcernRule struct { + name string + description string + codes []string + passCode string + passMessage string +} + +func (r *simpleConcernRule) Name() string { return r.name } +func (r *simpleConcernRule) Description() string { return r.description } + +func (r *simpleConcernRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadSMTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + // Null-MX declares the domain does not accept mail; every other rule + // becomes vacuous. Return "not tested" so those lines do not count as + // a pass or fail in the aggregator. + if data.MX.NullMX { + return []sdk.CheckState{notTestedState(r.name+".skipped", "Skipped: domain declares a null MX (refuses mail).")} + } + issues := issuesByCodes(data, r.codes...) + if len(issues) == 0 { + return []sdk.CheckState{passState(r.passCode, r.passMessage)} + } + return statesFromIssues(issues) +} diff --git a/checker/rules_tls_quality.go b/checker/rules_tls_quality.go new file mode 100644 index 0000000..de9027e --- /dev/null +++ b/checker/rules_tls_quality.go @@ -0,0 +1,36 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// tlsQualityRule folds findings from a downstream TLS checker (cert chain, +// hostname match, expiry, …) into SMTP rule output, so they show up on the +// SMTP service page without the user opening a separate report. +type tlsQualityRule struct{} + +func (r *tlsQualityRule) Name() string { return "smtp.tls_quality" } +func (r *tlsQualityRule) Description() string { + return "Folds downstream TLS checker findings (certificate chain, hostname match, expiry) onto the SMTP service." +} + +func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadSMTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.MX.NullMX { + return []sdk.CheckState{notTestedState("smtp.tls_quality.skipped", "Skipped: domain declares a null MX.")} + } + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + if len(related) == 0 { + return []sdk.CheckState{notTestedState("smtp.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")} + } + issues := tlsIssuesFromRelated(related) + if len(issues) == 0 { + return []sdk.CheckState{passState("smtp.tls_quality.ok", "Downstream TLS checker reports no issues on the MX endpoints.")} + } + return statesFromIssues(issues) +} diff --git a/checker/smtp.go b/checker/smtp.go new file mode 100644 index 0000000..bc7dfc1 --- /dev/null +++ b/checker/smtp.go @@ -0,0 +1,230 @@ +package checker + +import ( + "bufio" + "crypto/tls" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +// smtpConn is a minimal ESMTP client focused on *observation*, not on +// transmitting mail: we never reach DATA, and every transaction is torn +// down with RSET + QUIT. Writing our own client (instead of using +// net/smtp) gives us access to the raw banner text, per-line extension +// responses, and the timing of each step. +type smtpConn struct { + raw net.Conn + br *bufio.Reader + wr io.Writer + timeout time.Duration +} + +func newSMTPConn(c net.Conn, timeout time.Duration) *smtpConn { + return &smtpConn{ + raw: c, + br: bufio.NewReader(c), + wr: c, + timeout: timeout, + } +} + +// touch extends the per-turn deadline on the underlying connection. +func (s *smtpConn) touch() { + if s.timeout > 0 { + _ = s.raw.SetDeadline(time.Now().Add(s.timeout)) + } +} + +// swap replaces the underlying connection (used after STARTTLS). +func (s *smtpConn) swap(c net.Conn) { + s.raw = c + s.br = bufio.NewReader(c) + s.wr = c + s.touch() +} + +// writeLine writes a single CRLF-terminated line. +func (s *smtpConn) writeLine(line string) error { + s.touch() + _, err := io.WriteString(s.wr, line+"\r\n") + return err +} + +// readResponse reads a multiline SMTP response ("xxx-...\r\nxxx ...\r\n") +// and returns (code, full-joined-text, raw-lines, error). +// +// Any transport error is surfaced immediately; a malformed line (missing +// 3-digit code, missing separator) yields an error with the offending +// text so callers can surface it verbatim. +func (s *smtpConn) readResponse() (code int, text string, lines []string, err error) { + for { + s.touch() + line, rerr := s.br.ReadString('\n') + if rerr != nil && line == "" { + return 0, "", lines, rerr + } + line = strings.TrimRight(line, "\r\n") + if len(line) < 4 { + return 0, line, append(lines, line), fmt.Errorf("short SMTP line %q", line) + } + cStr := line[:3] + sep := line[3] + rest := line[4:] + c, nerr := strconv.Atoi(cStr) + if nerr != nil { + return 0, line, append(lines, line), fmt.Errorf("bad SMTP code in %q", line) + } + if code == 0 { + code = c + } + lines = append(lines, line) + if sep == ' ' { + text += rest + return code, text, lines, nil + } + if sep == '-' { + text += rest + "\n" + continue + } + return 0, line, lines, fmt.Errorf("bad separator %q in %q", sep, line) + } +} + +// cmd writes a command and returns the server response. +func (s *smtpConn) cmd(line string) (int, string, []string, error) { + if err := s.writeLine(line); err != nil { + return 0, "", nil, err + } + return s.readResponse() +} + +// close attempts a graceful QUIT then closes the socket. Errors are +// swallowed; the caller has already captured everything interesting. +func (s *smtpConn) close() { + _ = s.writeLine("QUIT") + _, _, _, _ = s.readResponse() + _ = s.raw.Close() +} + +// parseBanner teases the announced hostname out of the 220 greeting. +// ESMTP convention is "220 ", but a number of +// servers deviate, so we return the first whitespace-delimited token that +// looks like a FQDN. Empty string when nothing looks plausible. +func parseBanner(text string) string { + for f := range strings.FieldsSeq(text) { + // Skip things that are obviously not a hostname. + if strings.Contains(f, "@") { + continue + } + if !strings.Contains(f, ".") { + continue + } + // Strip trailing punctuation. + f = strings.TrimRight(f, ",;:.") + if looksLikeHostname(f) { + return f + } + } + return "" +} + +func looksLikeHostname(s string) bool { + if s == "" || len(s) > 253 { + return false + } + // A hostname has at least one dot and no invalid characters. + if strings.ContainsAny(s, " \t\r\n<>\"()[]") { + return false + } + // We tolerate '_' even though RFC 1123 forbids it in hostnames: this + // helper only classifies tokens parsed out of an SMTP banner for + // display, never for routing or certificate matching, and a number of + // real-world MTAs announce names with underscores. + for _, r := range s { + if r == '.' || r == '-' || r == '_' || + (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + continue + } + return false + } + return true +} + +// parseEHLO splits an EHLO response into its keyword/arg lines. The +// first line is the greeting hostname, subsequent lines are extensions. +func parseEHLO(lines []string) (greeting string, extensions []string) { + for i, l := range lines { + if len(l) < 4 { + continue + } + payload := strings.TrimSpace(l[4:]) + if i == 0 { + greeting = payload + continue + } + extensions = append(extensions, payload) + } + return greeting, extensions +} + +// extensionLookup indexes parsed EHLO extensions by their (uppercased) +// keyword, preserving the argument portion unchanged. +type extensionLookup map[string]string + +func buildExtensions(exts []string) extensionLookup { + m := extensionLookup{} + for _, e := range exts { + kw, arg, _ := strings.Cut(e, " ") + m[strings.ToUpper(strings.TrimSpace(kw))] = strings.TrimSpace(arg) + } + return m +} + +func (m extensionLookup) has(kw string) bool { + _, ok := m[kw] + return ok +} + +// parseSize extracts the integer argument of the SIZE extension (0 when +// absent or unparseable). SIZE may be advertised without an argument; +// we treat that as "no limit declared". +func (m extensionLookup) parseSize() uint64 { + v, ok := m["SIZE"] + if !ok || v == "" { + return 0 + } + n, err := strconv.ParseUint(strings.Fields(v)[0], 10, 64) + if err != nil { + return 0 + } + return n +} + +// parseAuth returns the SASL mechanisms advertised under the AUTH +// extension, upper-cased for easy comparison. Empty slice when AUTH is +// not advertised. +func (m extensionLookup) parseAuth() []string { + v, ok := m["AUTH"] + if !ok || v == "" { + return nil + } + out := strings.Fields(v) + for i := range out { + out[i] = strings.ToUpper(out[i]) + } + return out +} + +// tlsProbeConfig mirrors the XMPP checker's stance: certificate +// verification is checker-tls' job, so we skip it here. +func tlsProbeConfig(serverName string) *tls.Config { + return &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: true, //nolint:gosec (cert validation is the TLS checker's job) + MinVersion: tls.VersionTLS10, + } +} diff --git a/checker/smtp_extra_test.go b/checker/smtp_extra_test.go new file mode 100644 index 0000000..26f297c --- /dev/null +++ b/checker/smtp_extra_test.go @@ -0,0 +1,192 @@ +package checker + +import ( + "strings" + "testing" +) + +func TestReadResponse_SingleLine(t *testing.T) { + sc := newSMTPConn(newFakeConn("220 mx ESMTP\r\n"), 0) + code, text, lines, err := sc.readResponse() + if err != nil { + t.Fatalf("err: %v", err) + } + if code != 220 || text != "mx ESMTP" || len(lines) != 1 { + t.Errorf("got code=%d text=%q lines=%v", code, text, lines) + } +} + +func TestReadResponse_BadCode(t *testing.T) { + sc := newSMTPConn(newFakeConn("abc nope\r\n"), 0) + if _, _, _, err := sc.readResponse(); err == nil { + t.Fatal("expected error for non-numeric code") + } +} + +func TestReadResponse_BadSeparator(t *testing.T) { + sc := newSMTPConn(newFakeConn("250?weird\r\n"), 0) + if _, _, _, err := sc.readResponse(); err == nil { + t.Fatal("expected error for bad separator") + } +} + +func TestReadResponse_ShortLine(t *testing.T) { + sc := newSMTPConn(newFakeConn("ok\r\n"), 0) + if _, _, _, err := sc.readResponse(); err == nil { + t.Fatal("expected error for short line") + } +} + +func TestReadResponse_EOF(t *testing.T) { + sc := newSMTPConn(newFakeConn(""), 0) + if _, _, _, err := sc.readResponse(); err == nil { + t.Fatal("expected EOF error") + } +} + +func TestCmd_WritesAndReads(t *testing.T) { + fc := newFakeConn("250 ok\r\n") + sc := newSMTPConn(fc, 0) + code, text, _, err := sc.cmd("EHLO mx.example.com") + if err != nil { + t.Fatalf("cmd: %v", err) + } + if code != 250 || text != "ok" { + t.Errorf("got code=%d text=%q", code, text) + } + if got := fc.writer.String(); got != "EHLO mx.example.com\r\n" { + t.Errorf("wrote %q", got) + } +} + +func TestParseEHLO_EmptyAndShort(t *testing.T) { + greeting, exts := parseEHLO([]string{"", "abc"}) // both too short to have a payload + if greeting != "" || len(exts) != 0 { + t.Errorf("expected empty greeting and no extensions, got %q %v", greeting, exts) + } +} + +func TestExtensionLookup_HasMissing(t *testing.T) { + idx := buildExtensions([]string{"PIPELINING", "STARTTLS"}) + if !idx.has("PIPELINING") || !idx.has("STARTTLS") { + t.Error("missing expected extension") + } + if idx.has("DSN") { + t.Error("DSN should be absent") + } +} + +func TestExtensionLookup_ParseSizeNoArg(t *testing.T) { + if got := buildExtensions([]string{"SIZE"}).parseSize(); got != 0 { + t.Errorf("SIZE no-arg: want 0, got %d", got) + } +} + +func TestExtensionLookup_ParseSizeJunk(t *testing.T) { + if got := buildExtensions([]string{"SIZE notanumber"}).parseSize(); got != 0 { + t.Errorf("SIZE junk: want 0, got %d", got) + } +} + +func TestExtensionLookup_ParseAuthMixedCase(t *testing.T) { + got := buildExtensions([]string{"AUTH plain login crammd5"}).parseAuth() + want := []string{"PLAIN", "LOGIN", "CRAMMD5"} + if len(got) != len(want) { + t.Fatalf("len: want %d got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d]: want %q got %q", i, want[i], got[i]) + } + } +} + +func TestExtensionLookup_ParseAuthEmpty(t *testing.T) { + if got := buildExtensions(nil).parseAuth(); got != nil { + t.Errorf("expected nil, got %v", got) + } + if got := buildExtensions([]string{"AUTH"}).parseAuth(); got != nil { + t.Errorf("AUTH no-arg: expected nil, got %v", got) + } +} + +func TestParseBanner_EdgeCases(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {" ", ""}, + {"foo@bar.com is not a hostname", ""}, // skipped: contains @ + {"hello world", ""}, // no dot + {"mx.example.com,", "mx.example.com"}, // trailing punct stripped + {"mx.example.com.", "mx.example.com"}, // trailing dot stripped + {strings.Repeat("a", 254) + ".com", ""}, // too long + {"mx-1.example.com hi", "mx-1.example.com"}, // hyphen ok + } + for _, c := range cases { + if got := parseBanner(c.in); got != c.want { + t.Errorf("parseBanner(%q): want %q, got %q", c.in, c.want, got) + } + } +} + +func TestLooksLikeHostname(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"a.b", true}, + {"mx.example.com", true}, + {"MX.EXAMPLE.COM", true}, + {"mx_underscore.example.com", true}, // current implementation tolerates _ + {"contains space", false}, + {"contains\tab", false}, + {"", false}, + {"emoji😀", false}, + } + for _, c := range cases { + if got := looksLikeHostname(c.in); got != c.want { + t.Errorf("looksLikeHostname(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestTLSProbeConfig(t *testing.T) { + cfg := tlsProbeConfig("mx.example.com") + if cfg.ServerName != "mx.example.com" { + t.Errorf("ServerName: got %q", cfg.ServerName) + } + if !cfg.InsecureSkipVerify { + t.Error("InsecureSkipVerify should be true (delegated to checker-tls)") + } +} + +func TestSMTPConn_Close_DoesNotPanic(t *testing.T) { + // close should write QUIT, attempt to read a 221, swallow errors, + // and Close the underlying conn. With an empty reader, the read + // fails, but close should not panic. + defer func() { + if r := recover(); r != nil { + t.Fatalf("close panicked: %v", r) + } + }() + fc := newFakeConn("") + sc := newSMTPConn(fc, 0) + sc.close() + if !strings.Contains(fc.writer.String(), "QUIT") { + t.Errorf("expected QUIT to be written, got %q", fc.writer.String()) + } +} + +func TestSMTPConn_Swap(t *testing.T) { + a := newFakeConn("") + b := newFakeConn("250 second\r\n") + sc := newSMTPConn(a, 0) + sc.swap(b) + code, _, _, err := sc.readResponse() + if err != nil { + t.Fatalf("read after swap: %v", err) + } + if code != 250 { + t.Errorf("read should come from b, got code %d", code) + } +} diff --git a/checker/smtp_test.go b/checker/smtp_test.go new file mode 100644 index 0000000..e86751b --- /dev/null +++ b/checker/smtp_test.go @@ -0,0 +1,177 @@ +package checker + +import ( + "bytes" + "io" + "net" + "strings" + "testing" + "time" +) + +// fakeConn turns a pair of in-memory buffers into a net.Conn stub. It is +// just enough surface for smtpConn to read responses and record what it +// wrote: no deadlines, no addressing. +type fakeConn struct { + reader io.Reader + writer bytes.Buffer +} + +func (f *fakeConn) Read(b []byte) (int, error) { return f.reader.Read(b) } +func (f *fakeConn) Write(b []byte) (int, error) { return f.writer.Write(b) } +func (f *fakeConn) Close() error { return nil } +func (f *fakeConn) LocalAddr() net.Addr { return nil } +func (f *fakeConn) RemoteAddr() net.Addr { return nil } +func (f *fakeConn) SetDeadline(t time.Time) error { return nil } +func (f *fakeConn) SetReadDeadline(t time.Time) error { return nil } +func (f *fakeConn) SetWriteDeadline(t time.Time) error { return nil } + +func newFakeConn(script string) *fakeConn { + return &fakeConn{reader: strings.NewReader(script)} +} + +func TestReadResponse_Multiline(t *testing.T) { + script := "250-mx.example.com Hello\r\n" + + "250-PIPELINING\r\n" + + "250-SIZE 52428800\r\n" + + "250-STARTTLS\r\n" + + "250-AUTH PLAIN LOGIN\r\n" + + "250 8BITMIME\r\n" + fc := newFakeConn(script) + sc := newSMTPConn(fc, 0) + code, _, lines, err := sc.readResponse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != 250 { + t.Errorf("code: want 250, got %d", code) + } + if len(lines) != 6 { + t.Fatalf("want 6 lines, got %d", len(lines)) + } + greeting, exts := parseEHLO(lines) + if greeting != "mx.example.com Hello" { + t.Errorf("greeting: got %q", greeting) + } + want := []string{"PIPELINING", "SIZE 52428800", "STARTTLS", "AUTH PLAIN LOGIN", "8BITMIME"} + if len(exts) != len(want) { + t.Fatalf("want %d extensions, got %d: %v", len(want), len(exts), exts) + } + for i, w := range want { + if exts[i] != w { + t.Errorf("ext[%d]: want %q, got %q", i, w, exts[i]) + } + } + idx := buildExtensions(exts) + if !idx.has("STARTTLS") { + t.Error("want STARTTLS") + } + if !idx.has("PIPELINING") { + t.Error("want PIPELINING") + } + if got := idx.parseSize(); got != 52428800 { + t.Errorf("SIZE: want 52428800, got %d", got) + } + auth := idx.parseAuth() + if len(auth) != 2 || auth[0] != "PLAIN" || auth[1] != "LOGIN" { + t.Errorf("AUTH: want [PLAIN LOGIN], got %v", auth) + } +} + +func TestParseBanner(t *testing.T) { + cases := []struct { + in, want string + }{ + {"mail.example.org ESMTP Postfix", "mail.example.org"}, + {"mx1.gmail.com ESMTP", "mx1.gmail.com"}, + {"server ready", ""}, // no dot, not a FQDN + {"220 mailbox", ""}, // no dot either + {"smtp-in.googlemail.com ESMTP qrs123 - gsmtp", "smtp-in.googlemail.com"}, + } + for _, c := range cases { + if got := parseBanner(c.in); got != c.want { + t.Errorf("parseBanner(%q): want %q, got %q", c.in, c.want, got) + } + } +} + +func TestSplitMail(t *testing.T) { + d, l, ok := splitMail("a@b.com") + if !ok || d != "b.com" || l != "a" { + t.Errorf("got (%q,%q,%v)", d, l, ok) + } + if _, _, ok := splitMail("nouser"); ok { + t.Error("missing @ should fail") + } + if _, _, ok := splitMail("@trailing"); ok { + t.Error("empty local should fail") + } + if _, _, ok := splitMail("trailing@"); ok { + t.Error("empty domain should fail") + } +} + +func TestDeriveIssues_NullMX(t *testing.T) { + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{NullMX: true}, + } + issues := deriveIssues(d) + if len(issues) != 1 || issues[0].Code != CodeNullMX { + t.Fatalf("want single null-mx issue, got %+v", issues) + } + if issues[0].Severity != SeverityInfo { + t.Errorf("null-MX severity: want info, got %q", issues[0].Severity) + } +} + +func TestDeriveIssues_OpenRelay(t *testing.T) { + tr := true + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{Records: []MXRecord{ + {Preference: 10, Target: "mx.example.com", IPv4: []string{"198.51.100.1"}}, + }}, + Endpoints: []EndpointProbe{ + { + Target: "mx.example.com", IP: "198.51.100.1", Address: "198.51.100.1:25", + TCPConnected: true, BannerReceived: true, BannerCode: 220, + EHLOReceived: true, STARTTLSOffered: true, STARTTLSUpgraded: true, + OpenRelay: &tr, OpenRelayResponse: "250 OK", OpenRelayRecipient: "postmaster@example.org", + FCrDNSPass: true, PTR: "mx.example.com", HasPipelining: true, Has8BITMIME: true, + }, + }, + } + issues := deriveIssues(d) + var sawOpen bool + for _, is := range issues { + if is.Code == CodeOpenRelay { + sawOpen = true + if is.Severity != SeverityCrit { + t.Errorf("open-relay severity: want crit, got %q", is.Severity) + } + } + } + if !sawOpen { + t.Errorf("expected open-relay issue, got %+v", issues) + } +} + +func TestDeriveIssues_MXCNAME(t *testing.T) { + d := &SMTPData{ + Domain: "example.com", + MX: MXLookup{Records: []MXRecord{ + {Preference: 10, Target: "mail.example.com", IsCNAME: true, CNAMEChain: []string{"mail.example.com", "real.example.net"}, IPv4: []string{"198.51.100.1"}}, + }}, + } + issues := deriveIssues(d) + var sawCNAME bool + for _, is := range issues { + if is.Code == CodeMXCNAME { + sawCNAME = true + } + } + if !sawCNAME { + t.Errorf("expected CNAME issue, got %+v", issues) + } +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..bb0dcd6 --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,158 @@ +package checker + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey is the observation key a TLS checker publishes for the +// endpoints we discover. Matches the convention used by checker-xmpp and +// documented in the happyDomain plan. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView is the permissive local view of a TLS checker payload. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issues []struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +// address returns the canonical "host:port" used as the matching key. +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +// parseTLSRelated decodes a RelatedObservation as a TLS probe, tolerating +// the two payload shapes the TLS checker produces ({"probes": {:…}} +// or a single top-level object). +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var keyed struct { + Probes map[string]tlsProbeView `json:"probes"` + } + if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { + if p, ok := keyed.Probes[r.Ref]; ok { + return &p + } + return nil + } + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +// tlsIssuesFromRelated converts downstream TLS observations into Issue +// entries that slot into our own aggregation. See the matching function +// in checker-xmpp for the design rationale. +func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { + var out []Issue + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if len(v.Issues) > 0 { + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + switch sev { + case SeverityCrit, SeverityWarn, SeverityInfo: + default: + continue + } + code := is.Code + if code == "" { + code = "tls.unknown" + } + out = append(out, Issue{ + Code: "smtp.tls." + code, + Severity: sev, + Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message), + Fix: is.Fix, + Endpoint: addr, + }) + } + continue + } + sev := v.worstSeverity() + if sev == "" { + continue + } + msg := "TLS issue reported on " + addr + switch { + case v.ChainValid != nil && !*v.ChainValid: + msg = "Invalid certificate chain on " + addr + case v.HostnameMatch != nil && !*v.HostnameMatch: + msg = "Certificate does not cover the MX hostname on " + addr + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0: + msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour: + msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + } + out = append(out, Issue{ + Code: "smtp.tls.probe", + Severity: sev, + Message: msg, + Fix: "See the TLS checker report for details.", + Endpoint: addr, + }) + } + return out +} + +// worstSeverity returns "crit" > "warn" > "info" across the TLS issues. +func (v *tlsProbeView) worstSeverity() string { + worst := "" + for _, is := range v.Issues { + switch strings.ToLower(is.Severity) { + case SeverityCrit: + return SeverityCrit + case SeverityWarn: + if worst != SeverityCrit { + worst = SeverityWarn + } + case SeverityInfo: + if worst == "" { + worst = SeverityInfo + } + } + } + if v.ChainValid != nil && !*v.ChainValid { + return SeverityCrit + } + if v.HostnameMatch != nil && !*v.HostnameMatch { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { + if worst != SeverityCrit { + return SeverityWarn + } + } + return worst +} diff --git a/checker/tls_related_test.go b/checker/tls_related_test.go new file mode 100644 index 0000000..9a4a43e --- /dev/null +++ b/checker/tls_related_test.go @@ -0,0 +1,200 @@ +package checker + +import ( + "encoding/json" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func TestTLSProbeView_AddressEndpointWins(t *testing.T) { + v := tlsProbeView{Endpoint: "mx.example.com:25", Host: "ignored", Port: 999} + if got := v.address(); got != "mx.example.com:25" { + t.Errorf("got %q", got) + } +} + +func TestTLSProbeView_AddressFromHostPort(t *testing.T) { + v := tlsProbeView{Host: "mx.example.com", Port: 25} + if got := v.address(); got != "mx.example.com:25" { + t.Errorf("got %q", got) + } +} + +func TestTLSProbeView_AddressEmpty(t *testing.T) { + v := tlsProbeView{} + if got := v.address(); got != "" { + t.Errorf("expected empty, got %q", got) + } + v2 := tlsProbeView{Host: "only-host"} + if got := v2.address(); got != "" { + t.Errorf("host without port should be empty, got %q", got) + } +} + +func TestParseTLSRelated_KeyedByRef(t *testing.T) { + payload := map[string]any{ + "probes": map[string]any{ + "ref-A": map[string]any{"host": "mx.example.com", "port": 25, "tls_version": "TLS1.3"}, + }, + } + r := sdk.RelatedObservation{Ref: "ref-A", Data: mustJSON(t, payload)} + v := parseTLSRelated(r) + if v == nil { + t.Fatal("expected match") + } + if v.TLSVersion != "TLS1.3" { + t.Errorf("got %q", v.TLSVersion) + } +} + +func TestParseTLSRelated_KeyedRefMissing(t *testing.T) { + payload := map[string]any{ + "probes": map[string]any{ + "some-other-ref": map[string]any{"host": "mx", "port": 25}, + }, + } + r := sdk.RelatedObservation{Ref: "ref-A", Data: mustJSON(t, payload)} + if got := parseTLSRelated(r); got != nil { + t.Errorf("expected nil for missing ref, got %+v", got) + } +} + +func TestParseTLSRelated_FlatTopLevel(t *testing.T) { + payload := map[string]any{"host": "mx.example.com", "port": 25} + r := sdk.RelatedObservation{Data: mustJSON(t, payload)} + v := parseTLSRelated(r) + if v == nil || v.Host != "mx.example.com" { + t.Errorf("got %+v", v) + } +} + +func TestParseTLSRelated_BadJSON(t *testing.T) { + r := sdk.RelatedObservation{Data: json.RawMessage("not json at all")} + if got := parseTLSRelated(r); got != nil { + t.Errorf("expected nil for bad json, got %+v", got) + } +} + +func TestTLSIssuesFromRelated_FromIssuesList(t *testing.T) { + payload := map[string]any{ + "host": "mx.example.com", "port": 25, + "issues": []map[string]any{ + {"code": "cert.expired", "severity": "crit", "message": "expired", "fix": "renew"}, + {"code": "cert.weakcipher", "severity": "WARN", "message": "weak"}, + {"code": "ignore-me", "severity": "bogus"}, // unknown severity → skipped + }, + } + related := []sdk.RelatedObservation{{Data: mustJSON(t, payload)}} + issues := tlsIssuesFromRelated(related) + if len(issues) != 2 { + t.Fatalf("want 2 issues, got %d (%+v)", len(issues), issues) + } + if issues[0].Code != "smtp.tls.cert.expired" || issues[0].Severity != SeverityCrit { + t.Errorf("first: %+v", issues[0]) + } + if issues[1].Severity != SeverityWarn { + t.Errorf("second severity: %q", issues[1].Severity) + } +} + +func TestTLSIssuesFromRelated_EmptyCode(t *testing.T) { + payload := map[string]any{ + "host": "mx", "port": 25, + "issues": []map[string]any{{"severity": "warn", "message": "x"}}, + } + related := []sdk.RelatedObservation{{Data: mustJSON(t, payload)}} + issues := tlsIssuesFromRelated(related) + if len(issues) != 1 || issues[0].Code != "smtp.tls.tls.unknown" { + t.Errorf("expected fallback code, got %+v", issues) + } +} + +func TestTLSIssuesFromRelated_FromShorthand_ChainInvalid(t *testing.T) { + chainBad := false + payload := map[string]any{ + "host": "mx", "port": 25, "chain_valid": chainBad, + } + related := []sdk.RelatedObservation{{Data: mustJSON(t, payload)}} + issues := tlsIssuesFromRelated(related) + if len(issues) != 1 || issues[0].Severity != SeverityCrit { + t.Errorf("expected single crit issue, got %+v", issues) + } +} + +func TestTLSIssuesFromRelated_HostnameMismatch(t *testing.T) { + hn := false + payload := map[string]any{"host": "mx", "port": 25, "hostname_match": hn} + related := []sdk.RelatedObservation{{Data: mustJSON(t, payload)}} + issues := tlsIssuesFromRelated(related) + if len(issues) != 1 || issues[0].Severity != SeverityCrit { + t.Errorf("expected hostname-mismatch crit, got %+v", issues) + } +} + +func TestTLSIssuesFromRelated_ExpiringSoon(t *testing.T) { + soon := time.Now().Add(48 * time.Hour) + payload := map[string]any{"host": "mx", "port": 25, "not_after": soon} + issues := tlsIssuesFromRelated([]sdk.RelatedObservation{{Data: mustJSON(t, payload)}}) + if len(issues) != 1 || issues[0].Severity != SeverityWarn { + t.Errorf("expected warn, got %+v", issues) + } +} + +func TestTLSIssuesFromRelated_Expired(t *testing.T) { + past := time.Now().Add(-1 * time.Hour) + payload := map[string]any{"host": "mx", "port": 25, "not_after": past} + issues := tlsIssuesFromRelated([]sdk.RelatedObservation{{Data: mustJSON(t, payload)}}) + if len(issues) != 1 || issues[0].Severity != SeverityCrit { + t.Errorf("expected crit (expired), got %+v", issues) + } +} + +func TestTLSIssuesFromRelated_NoSeverityNoIssue(t *testing.T) { + yes := true + notAfter := time.Now().Add(365 * 24 * time.Hour) + payload := map[string]any{ + "host": "mx", "port": 25, + "chain_valid": yes, "hostname_match": yes, "not_after": notAfter, + } + if got := tlsIssuesFromRelated([]sdk.RelatedObservation{{Data: mustJSON(t, payload)}}); len(got) != 0 { + t.Errorf("happy path: expected no issues, got %+v", got) + } +} + +func TestWorstSeverity_Ordering(t *testing.T) { + v := tlsProbeView{ + Issues: []struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + }{ + {Code: "a", Severity: "info"}, + {Code: "b", Severity: "warn"}, + }, + } + if got := v.worstSeverity(); got != SeverityWarn { + t.Errorf("info+warn → warn, got %q", got) + } + + v.Issues = append(v.Issues, struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + }{Code: "c", Severity: "CRIT"}) + if got := v.worstSeverity(); got != SeverityCrit { + t.Errorf("with crit → crit, got %q", got) + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..d74454d --- /dev/null +++ b/checker/types.go @@ -0,0 +1,188 @@ +// Package checker implements the SMTP (MX) server checker for happyDomain. +// +// It probes a domain's inbound-mail deployment (MX discovery, TCP +// reachability, ESMTP banner & EHLO, STARTTLS negotiation, open-relay +// posture, RFC 5321 § 4.5.1 postmaster acceptance, PTR / FCrDNS) and +// reports actionable findings. +// +// TLS certificate chain / SAN / expiry / cipher posture is intentionally +// out of scope: we publish each MX target as a DiscoveryEntry of type +// tls.endpoint.v1 with STARTTLS="smtp" so checker-tls picks up the +// connection and runs the TLS posture checks itself. The resulting +// observations flow back into our rule and HTML report via the SDK's +// ObservationGetter.GetRelated / ReportContext.Related path. +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ObservationKeySMTP is the key under which this checker's observation +// payload is stored. +const ObservationKeySMTP sdk.ObservationKey = "smtp" + +// SMTPData is the full observation stored per run. +type SMTPData struct { + Domain string `json:"domain"` + RunAt string `json:"run_at"` + + MX MXLookup `json:"mx"` + Endpoints []EndpointProbe `json:"endpoints"` + Coverage Coverage `json:"coverage"` +} + +// MXLookup captures the MX discovery step. +type MXLookup struct { + Records []MXRecord `json:"records,omitempty"` + // Error is a non-NXDOMAIN DNS failure (servfail, timeout, …). + Error string `json:"error,omitempty"` + // NullMX is true when the sole record is "." with preference 0 (RFC 7505). + NullMX bool `json:"null_mx,omitempty"` + // ImplicitMX is true when no MX was published and we fell back to the + // A/AAAA of the domain itself (RFC 5321 § 5.1, implicit MX). + ImplicitMX bool `json:"implicit_mx,omitempty"` +} + +// MXRecord is a single MX RR target, expanded with per-target DNS checks. +type MXRecord struct { + Preference uint16 `json:"preference"` + Target string `json:"target"` + + // Resolution. + IPv4 []string `json:"ipv4,omitempty"` + IPv6 []string `json:"ipv6,omitempty"` + + // Error from A/AAAA resolution (empty string when OK). + ResolveError string `json:"resolve_error,omitempty"` + + // IsCNAME flags targets pointed at via a CNAME chain, forbidden by + // RFC 5321 § 5.1 ("the domain name that appears in the RDATA SHOULD + // have an associated address record"): senders MAY reject or fail. + IsCNAME bool `json:"is_cname,omitempty"` + CNAMEChain []string `json:"cname_chain,omitempty"` + + // IsIPLiteral flags targets that look like an IP address instead of a + // hostname (RFC 5321 § 5.1 forbids it). + IsIPLiteral bool `json:"is_ip_literal,omitempty"` +} + +// EndpointProbe is the result of probing one (target, ip, port=25) tuple. +type EndpointProbe struct { + Target string `json:"target"` + Port uint16 `json:"port"` + IP string `json:"ip"` + IsIPv6 bool `json:"is_ipv6,omitempty"` + + // Address is "ip:port"; used as a stable key. + Address string `json:"address"` + + // Timing & errors. + ElapsedMS int64 `json:"elapsed_ms"` + Error string `json:"error,omitempty"` + + // Connection stages. + TCPConnected bool `json:"tcp_connected"` + BannerReceived bool `json:"banner_received"` + BannerLine string `json:"banner_line,omitempty"` + BannerHostname string `json:"banner_hostname,omitempty"` + BannerCode int `json:"banner_code,omitempty"` + EHLOReceived bool `json:"ehlo_received"` + EHLOHostname string `json:"ehlo_hostname,omitempty"` + EHLOFallbackHELO bool `json:"ehlo_fallback_helo,omitempty"` + + // Pre-TLS extensions. + Extensions []string `json:"extensions,omitempty"` + STARTTLSOffered bool `json:"starttls_offered"` + AUTHPreTLS []string `json:"auth_pre_tls,omitempty"` + SizeLimit uint64 `json:"size_limit,omitempty"` + HasPipelining bool `json:"has_pipelining,omitempty"` + Has8BITMIME bool `json:"has_8bitmime,omitempty"` + HasSMTPUTF8 bool `json:"has_smtputf8,omitempty"` + HasCHUNKING bool `json:"has_chunking,omitempty"` + HasDSN bool `json:"has_dsn,omitempty"` + HasENHANCEDCODE bool `json:"has_enhancedstatuscodes,omitempty"` + + // STARTTLS negotiation. + STARTTLSUpgraded bool `json:"starttls_upgraded,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + TLSCipher string `json:"tls_cipher,omitempty"` + + // Post-TLS extensions (typically identical or larger than pre-TLS). + PostTLSExtensions []string `json:"post_tls_extensions,omitempty"` + AUTHPostTLS []string `json:"auth_post_tls,omitempty"` + + // Reverse DNS / FCrDNS. + PTR string `json:"ptr,omitempty"` + PTRError string `json:"ptr_error,omitempty"` + FCrDNSPass bool `json:"fcrdns_pass,omitempty"` + + // Mail transaction probes. + NullSenderAccepted *bool `json:"null_sender_accepted,omitempty"` + NullSenderResponse string `json:"null_sender_response,omitempty"` + + PostmasterAccepted *bool `json:"postmaster_accepted,omitempty"` + PostmasterResponse string `json:"postmaster_response,omitempty"` + + OpenRelay *bool `json:"open_relay,omitempty"` + OpenRelayResponse string `json:"open_relay_response,omitempty"` + OpenRelayRecipient string `json:"open_relay_recipient,omitempty"` +} + +// Coverage summarises which axes are working at the domain level. +type Coverage struct { + HasIPv4 bool `json:"has_ipv4"` + HasIPv6 bool `json:"has_ipv6"` + AnyReachable bool `json:"any_reachable"` + AnyBanner bool `json:"any_banner"` + AnyEHLO bool `json:"any_ehlo"` + AnySTARTTLS bool `json:"any_starttls"` + AllSTARTTLS bool `json:"all_starttls"` + AllAcceptMail bool `json:"all_accept_mail"` +} + +// Issue is a structured finding, consumed by both the rule and the HTML report. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` // "info" | "warn" | "crit" + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Target string `json:"target,omitempty"` +} + +// Severities (string for stable JSON, independent of sdk.Status numeric values). +const ( + SeverityInfo = "info" + SeverityWarn = "warn" + SeverityCrit = "crit" +) + +// Issue codes. +const ( + CodeNoMX = "smtp.no_mx" + CodeMXLookupFailed = "smtp.mx.lookup_failed" + CodeNullMX = "smtp.null_mx" + CodeImplicitMX = "smtp.mx.implicit" + CodeMXCNAME = "smtp.mx.cname" + CodeMXIPLiteral = "smtp.mx.ip_literal" + CodeMXResolveFailed = "smtp.mx.resolve_failed" + CodeNoAddresses = "smtp.mx.no_addresses" + CodeTCPUnreachable = "smtp.tcp.unreachable" + CodeBannerMissing = "smtp.banner.missing" + CodeBannerInvalid = "smtp.banner.invalid" + CodeEHLOFailed = "smtp.ehlo.failed" + CodeEHLOFallback = "smtp.ehlo.fallback_helo" + CodeSTARTTLSMissing = "smtp.starttls.missing" + CodeSTARTTLSFailed = "smtp.starttls.failed" + CodeAUTHOverPlain = "smtp.auth.plaintext" + CodePTRMissing = "smtp.ptr.missing" + CodeFCrDNSMismatch = "smtp.fcrdns.mismatch" + CodeNullSenderReject = "smtp.null_sender.rejected" + CodePostmasterReject = "smtp.postmaster.rejected" + CodeOpenRelay = "smtp.open_relay" + CodeNoPipelining = "smtp.no_pipelining" + CodeNo8BITMIME = "smtp.no_8bitmime" + CodeNoIPv6 = "smtp.no_ipv6" + CodeAllEndpointsDown = "smtp.all_endpoints_down" + CodeAllNoSTARTTLS = "smtp.all_no_starttls" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..227a27a --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.happydns.org/checker-smtp + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.5.0 + git.happydns.org/checker-tls v0.6.2 + github.com/miekg/dns v1.1.72 +) + +require ( + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/tools v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4faaeb7 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= +git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3f3ce0b --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-sdk-go/checker/server" + smtp "git.happydns.org/checker-smtp/checker" +) + +// Version is the standalone binary's version. Overridden at link time: +// +// go build -ldflags "-X main.Version=1.2.3" . +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + smtp.Version = Version + + srv := server.New(smtp.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..ba62613 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,20 @@ +// Command plugin is the happyDomain plugin entrypoint for the SMTP checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + smtp "git.happydns.org/checker-smtp/checker" +) + +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading the +// .so file. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + smtp.Version = Version + prvd := smtp.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}