Initial commit

This commit is contained in:
nemunaire 2026-04-23 11:25:26 +07:00
commit 485c5a4a1d
33 changed files with 5407 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-smtp
checker-smtp.so

17
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

28
Makefile Normal file
View file

@ -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

29
NOTICE Normal file
View file

@ -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

190
README.md Normal file
View file

@ -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:<postmaster@domain>`; RFC 5321 § 4.5.1).
17. **Open-relay probe** (`MAIL FROM:<checker@…>` then `RCPT TO:<postmaster@example.com>`; 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:<postmaster@domain>` (RFC 5321 § 4.5.1). |
| Run | `test_open_relay` | `true` | Probe `RCPT TO:<recipient-outside-domain>` 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`.

563
checker/collect.go Normal file
View file

@ -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:<body>}.
// 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
}

307
checker/collect_test.go Normal file
View file

@ -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", "<bracket>.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<b>@example.com", // bracket in local
"a,b@example.com", // comma in local
"\"quoted\"@example.com", // quoted local
"a@with space.com",
"a@<bracket>.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:<x>",
})
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)
}
}
}

85
checker/definition.go Normal file
View file

@ -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:<postmaster@domain>",
Default: true,
Description: "RFC 5321 § 4.5.1 requires every host to accept mail for <postmaster>. 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:<probe@happydomain.test> RCPT TO:<postmaster@example.com>. 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,
},
}
}

134
checker/interactive.go Normal file
View file

@ -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:<postmaster@domain>",
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
}
}

128
checker/interactive_test.go Normal file
View file

@ -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)
}
}
}

288
checker/issues.go Normal file
View file

@ -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:<postmaster@%s> (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
}

326
checker/issues_test.go Normal file
View file

@ -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)
}
}

370
checker/probe_test.go Normal file
View file

@ -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:<postmaster@" + in.domain + ">")
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)
}
}

72
checker/provider.go Normal file
View file

@ -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))
}

101
checker/provider_test.go Normal file
View file

@ -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")
}
}

663
checker/report.go Normal file
View file

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMTP Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e3a8a; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.danger { background: #fee2e2; color: #991b1b; }
.chip.good { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.banner-text { font-family: ui-monospace, monospace; font-size: .78rem;
background: #f9fafb; border: 1px solid #e5e7eb; padding: .3rem .5rem;
border-radius: 4px; color: #374151; word-break: break-all; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.check-info { color: #6b7280; }
.relay-alert {
background: #fef2f2; border: 2px solid #dc2626;
border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem;
}
.relay-alert strong { color: #991b1b; }
</style>
</head>
<body>
<div class="hd">
<h1>SMTP: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .NullMX}}<span class="badge info">null MX (refuses mail)</span>{{else}}
{{if .AllSTARTTLS}}<span class="badge ok">all STARTTLS</span>
{{else if .AnySTARTTLS}}<span class="badge warn">partial STARTTLS</span>
{{else}}<span class="badge fail">no STARTTLS</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
{{end}}
</div>
<div class="meta">Checked {{.RunAt}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / MX</h2>
{{if .NullMX}}
<p class="note">This domain publishes a <strong>null MX record</strong>: it explicitly does not accept email (RFC 7505).</p>
{{else if .ImplicitMX}}
<p class="note">No MX record is published; senders will fall back to the domain's A/AAAA (implicit MX, discouraged).</p>
{{else if .MXError}}
<p class="note">MX lookup failed: <code>{{.MXError}}</code></p>
{{else if .MX}}
<table>
<tr><th>Pref</th><th>Target</th><th>IPv4</th><th>IPv6</th><th>Issues</th></tr>
{{range .MX}}
<tr>
<td>{{.Preference}}</td>
<td><code>{{.Target}}</code></td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
<td>
{{if .IsIPLiteral}}<span class="check-fail">IP literal</span>{{end}}
{{if .IsCNAME}}<span class="check-fail">CNAME chain: {{range .CNAMEChain}}<code>{{.}}</code> {{end}}</span>{{end}}
{{if .ResolveErr}}<span class="check-fail">resolve: {{.ResolveErr}}</span>{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No MX records found.</p>
{{end}}
</div>
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.Target}} · {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP :25</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if .BannerLine}}
<dt>Banner</dt><dd>
<div class="banner-text">{{.BannerCode}} {{.BannerLine}}</div>
{{if .BannerHostname}}<div class="note">announced name: <code>{{.BannerHostname}}</code></div>{{end}}
</dd>
{{end}}
<dt>EHLO</dt><dd>
{{if .EHLOFallbackHELO}}<span class="check-fail">&#10007; EHLO rejected, only HELO works</span>
{{else if .EHLOReceived}}<span class="check-ok">&#10003; accepted{{if .EHLOHostname}} (<code>{{.EHLOHostname}}</code>){{end}}</span>
{{else}}<span class="check-fail">&#10007; failed</span>{{end}}
</dd>
{{if .EHLOReceived}}
<dt>Extensions</dt><dd>
<div class="chiprow">
{{if .STARTTLSOffered}}<span class="chip good">STARTTLS</span>{{else}}<span class="chip danger">no STARTTLS</span>{{end}}
{{if .HasPipelining}}<span class="chip good">PIPELINING</span>{{else}}<span class="chip danger">no PIPELINING</span>{{end}}
{{if .Has8BITMIME}}<span class="chip">8BITMIME</span>{{end}}
{{if .HasSMTPUTF8}}<span class="chip">SMTPUTF8</span>{{end}}
{{if .HasCHUNKING}}<span class="chip">CHUNKING</span>{{end}}
{{if .HasDSN}}<span class="chip">DSN</span>{{end}}
{{if .HasENHANCEDCODE}}<span class="chip">ENHANCEDSTATUSCODES</span>{{end}}
{{if .SizeLimit}}<span class="chip">SIZE {{humanBytes .SizeLimit}}</span>{{end}}
</div>
</dd>
{{end}}
{{if .AUTHPreTLS}}
<dt>AUTH pre-TLS</dt><dd>
<span class="check-fail">&#10007; advertised without TLS:</span>
{{range .AUTHPreTLS}}<span class="chip danger">{{.}}</span> {{end}}
</dd>
{{end}}
{{if .AUTHPostTLS}}
<dt>AUTH post-TLS</dt><dd>{{range .AUTHPostTLS}}<span class="chip">{{.}}</span> {{end}}</dd>
{{end}}
<dt>STARTTLS</dt><dd>
{{if .STARTTLSUpgraded}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}}</span>
{{else if .STARTTLSOffered}}<span class="check-fail">&#10007; handshake failed</span>
{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
</dd>
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}{{end}}
{{if .HostnameMatch}} &middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}{{end}}
{{if not .NotAfter.IsZero}} &middot; expires <code>{{.NotAfter.Format "2006-01-02"}}</code>{{end}}
{{if not .CheckedAt.IsZero}}<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>{{end}}
{{range .Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</dd>
{{end}}
<dt>PTR</dt><dd>
{{if .PTR}}<code>{{.PTR}}</code>
{{if .FCrDNSPass}}&middot; <span class="check-ok">&#10003; FCrDNS</span>
{{else}}&middot; <span class="check-fail">&#10007; FCrDNS mismatch</span>{{end}}
{{else}}<span class="check-fail">&#10007; no PTR</span>{{if .PTRError}} <span class="note">({{.PTRError}})</span>{{end}}{{end}}
</dd>
{{if .NullSenderState}}
<dt>Null sender</dt><dd>
<span class="check-{{.NullSenderClass}}">{{.NullSenderState}}</span>
<div class="note">{{.NullSenderResponse}}</div>
</dd>
{{end}}
{{if .PostmasterState}}
<dt>Postmaster</dt><dd>
<span class="check-{{.PostmasterClass}}">{{.PostmasterState}}</span>
<div class="note">{{.PostmasterResponse}}</div>
</dd>
{{end}}
{{if .OpenRelayState}}
<dt>Open relay</dt><dd>
<span class="check-{{.OpenRelayClass}}">{{.OpenRelayState}}</span>
<div class="note">rcpt=<code>{{.OpenRelayRecipient}}</code>: {{.OpenRelayResponse}}</div>
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry and cipher posture, run the TLS checker on port 25 with STARTTLS=smtp.{{end}}</p>
</body>
</html>`))
// 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
}

240
checker/report_test.go Normal file
View file

@ -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, "<!DOCTYPE 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")
}
}

214
checker/rule.go Normal file
View file

@ -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:<postmaster@domain> (RFC 5321 § 4.5.1).",
codes: []string{CodePostmasterReject},
passCode: "smtp.postmaster.ok",
passMessage: "Endpoints accept mail for <postmaster>.",
},
&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,
}
}

294
checker/rule_test.go Normal file
View file

@ -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)
}
}

33
checker/rules_null_mx.go Normal file
View file

@ -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.")}
}

39
checker/rules_simple.go Normal file
View file

@ -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)
}

View file

@ -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)
}

230
checker/smtp.go Normal file
View file

@ -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 <hostname> <greeting-text>", 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,
}
}

192
checker/smtp_extra_test.go Normal file
View file

@ -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},
{"<bracket>", 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)
}
}

177
checker/smtp_test.go Normal file
View file

@ -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)
}
}

158
checker/tls_related.go Normal file
View file

@ -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": {<ref>:…}}
// 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
}

200
checker/tls_related_test.go Normal file
View file

@ -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)
}
}

188
checker/types.go Normal file
View file

@ -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"
)

17
go.mod Normal file
View file

@ -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
)

18
go.sum Normal file
View file

@ -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=

27
main.go Normal file
View file

@ -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)
}
}

20
plugin/plugin.go Normal file
View file

@ -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
}