From 8f41185302efe101589d412f58da32923c4ff664 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 | 14 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 29 ++ README.md | 139 ++++++++++ checker/collect.go | 519 ++++++++++++++++++++++++++++++++++ checker/definition.go | 85 ++++++ checker/issues.go | 288 +++++++++++++++++++ checker/provider.go | 77 ++++++ checker/report.go | 614 +++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 125 +++++++++ checker/smtp.go | 227 +++++++++++++++ checker/smtp_test.go | 177 ++++++++++++ checker/tls_related.go | 158 +++++++++++ checker/types.go | 189 +++++++++++++ go.mod | 17 ++ go.sum | 20 ++ main.go | 27 ++ plugin/plugin.go | 19 ++ 20 files changed, 2775 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/issues.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/smtp.go create mode 100644 checker/smtp_test.go create mode 100644 checker/tls_related.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..0ff4c4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-smtp . + +FROM scratch +COPY --from=builder /checker-smtp /checker-smtp +EXPOSE 8080 +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..9177c15 --- /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 -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 ./... + +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..2a0ecfb --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# 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. + +TLS certificate chain / SAN / expiry / cipher posture is **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 aggregates all of the above into a single `CheckState` +(OK / WARN / CRIT / INFO), with the worst severity winning. 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 +``` + +### 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). + +## 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..bda58db --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,519 @@ +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") + } + + helo, _ := sdk.GetOption[string](opts, "helo_name") + helo = strings.TrimSpace(helo) + if helo == "" { + helo = defaultEHLOName + } + + 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 == "" { + 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 + data.Issues = deriveIssues(data) + 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) + data.Issues = deriveIssues(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 + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(in.timeout)) + + sc := newSMTPConn(conn, in.timeout) + + // 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]) + sc.close() + 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@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org") + 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@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org") + 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 + } + + sc.close() + return ep +} + +func firstNonEmpty(a ...string) string { + for _, s := range a { + if s != "" { + return s + } + } + return "" +} + +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 +} + +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/definition.go b/checker/definition.go new file mode 100644 index 0000000..db4ef0a --- /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 Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "smtp", + Name: "SMTP Server (MX)", + 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: []sdk.CheckRule{Rule()}, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 6 * time.Hour, + }, + } +} 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/provider.go b/checker/provider.go new file mode 100644 index 0000000..762dc92 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,77 @@ +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 +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *smtpProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} + +// 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/report.go b/checker/report.go new file mode 100644 index 0000000..2dbfbb2 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,614 @@ +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)) + 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) reportData { + tlsIssues := tlsIssuesFromRelated(related) + tlsByAddr := indexTLSByAddress(related) + + allIssues := append([]Issue(nil), d.Issues...) + allIssues = append(allIssues, tlsIssues...) + + 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(allIssues) > 0, + HasTLSPosture: len(tlsByAddr) > 0, + } + + worst := SeverityInfo + for _, is := range allIssues { + if is.Severity == SeverityCrit { + worst = SeverityCrit + break + } + if is.Severity == SeverityWarn { + worst = SeverityWarn + } + } + if d.MX.NullMX { + view.StatusLabel = "NULL MX" + view.StatusClass = "info" + } else if len(allIssues) == 0 { + view.StatusLabel = "OK" + view.StatusClass = "ok" + } else { + switch worst { + case SeverityCrit: + view.StatusLabel = "FAIL" + view.StatusClass = "fail" + case SeverityWarn: + view.StatusLabel = "WARN" + view.StatusClass = "warn" + default: + view.StatusLabel = "INFO" + view.StatusClass = "info" + } + } + + sevRank := func(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } + } + sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) + for _, is := range allIssues { + view.Fixes = append(view.Fixes, reportFix{ + Severity: is.Severity, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + Endpoint: is.Endpoint, + Target: is.Target, + }) + } + + 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 +} + +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/rule.go b/checker/rule.go new file mode 100644 index 0000000..9a76293 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,125 @@ +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Rule() sdk.CheckRule { + return &smtpRule{} +} + +type smtpRule struct{} + +func (r *smtpRule) Name() string { + return "smtp_server" +} + +func (r *smtpRule) Description() string { + return "Checks MX discovery, SMTP connectivity, STARTTLS, PTR/FCrDNS and mail-acceptance posture for an email-receiving domain" +} + +func (r *smtpRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { + var data SMTPData + if err := obs.Get(ctx, ObservationKeySMTP, &data); err != nil { + return sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load SMTP observation: %v", err), + Code: "smtp.observation_error", + } + } + + issues := append([]Issue(nil), data.Issues...) + + // Fold related TLS observations so cert/chain problems show up on the + // SMTP service page without requiring the user to open a separate + // report. + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + issues = append(issues, tlsIssuesFromRelated(related)...) + + worst := sdk.StatusOK + critMsgs, warnMsgs := []string{}, []string{} + var firstCritCode, firstWarnCode string + + for _, is := range issues { + switch is.Severity { + case SeverityCrit: + if worst < sdk.StatusCrit { + worst = sdk.StatusCrit + } + if firstCritCode == "" { + firstCritCode = is.Code + } + critMsgs = append(critMsgs, is.Message) + case SeverityWarn: + if worst < sdk.StatusWarn { + worst = sdk.StatusWarn + } + if firstWarnCode == "" { + firstWarnCode = is.Code + } + warnMsgs = append(warnMsgs, is.Message) + } + } + + // Null-MX is a *declared* refusal of mail; it's an INFO, not a fail. + 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, + }, + } + } + + meta := map[string]any{ + "has_ipv4": data.Coverage.HasIPv4, + "has_ipv6": data.Coverage.HasIPv6, + "any_starttls": data.Coverage.AnySTARTTLS, + "all_starttls": data.Coverage.AllSTARTTLS, + "all_accept_mail": data.Coverage.AllAcceptMail, + "mx_count": len(data.MX.Records), + "endpoint_count": len(data.Endpoints), + "native_issues": len(data.Issues), + "related_tls_count": len(related), + } + + switch worst { + case sdk.StatusOK: + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: fmt.Sprintf("SMTP operational (%d MX, %d endpoints, TLS=%v)", len(data.MX.Records), len(data.Endpoints), data.Coverage.AllSTARTTLS), + Code: "smtp.ok", + Meta: meta, + } + case sdk.StatusWarn: + return sdk.CheckState{ + Status: sdk.StatusWarn, + Message: "SMTP works with warnings: " + joinTop(warnMsgs, 2), + Code: firstWarnCode, + Meta: meta, + } + default: + return sdk.CheckState{ + Status: sdk.StatusCrit, + Message: "SMTP broken: " + joinTop(critMsgs, 2), + Code: firstCritCode, + Meta: meta, + } + } +} + +func joinTop(msgs []string, n int) string { + if len(msgs) == 0 { + return "" + } + if len(msgs) <= n { + return strings.Join(msgs, "; ") + } + return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n) +} diff --git a/checker/smtp.go b/checker/smtp.go new file mode 100644 index 0000000..7ef1747 --- /dev/null +++ b/checker/smtp.go @@ -0,0 +1,227 @@ +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 { + fields := strings.Fields(text) + for _, f := range fields { + // 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 + } + 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_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/types.go b/checker/types.go new file mode 100644 index 0000000..64ed467 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,189 @@ +// 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"` + Issues []Issue `json:"issues"` +} + +// 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..2c43643 --- /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.1.0 + git.happydns.org/checker-tls v0.1.0 + 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..121c66b --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= +git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y= +git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.1.0 h1:eFHn9B5GKfc/lKdRRgOu+gf5FiMqwna43ftIXTbBLhc= +git.happydns.org/checker-tls v0.1.0/go.mod h1:dJR9pIejNQU6TR0R0Ik4bXX77+q1jVeOVvaRM5iYZt8= +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..f0b1e33 --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + 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 + + server := sdk.NewServer(smtp.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..878ea3a --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,19 @@ +// 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 + return smtp.Definition(), smtp.Provider(), nil +}