Initial commit

This commit is contained in:
nemunaire 2026-04-23 11:25:26 +07:00
commit f6d316f049
21 changed files with 2884 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-smtp .
FROM scratch
COPY --from=builder /checker-smtp /checker-smtp
EXPOSE 8080
ENTRYPOINT ["/checker-smtp"]

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 -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

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

147
README.md Normal file
View file

@ -0,0 +1,147 @@
# checker-smtp
Deep SMTP checker for the MX-based inbound mail service of a
[happyDomain](https://www.happydomain.org/) domain.
For every MX target of the zone, it performs the live probes a human
operator would run with `swaks` or `telnet … 25`: TCP connect, ESMTP
banner & EHLO, STARTTLS negotiation, mail-transaction (null sender,
postmaster, open-relay) probes, reverse DNS / FCrDNS, extension
inventory, and IPv4/IPv6 coverage. The result is an actionable HTML
report whose "What to fix" panel foregrounds the most common real-world
failures rather than burying them in endpoint tabs.
TLS certificate chain / SAN / expiry / cipher posture is **out of scope**:
a dedicated TLS checker handles that. This checker only confirms STARTTLS
completes and records the negotiated TLS version/cipher for context.
We publish each MX target as a `DiscoveryEntry` of type
`tls.endpoint.v1` (contract: `git.happydns.org/checker-tls/contract`)
with `STARTTLS="smtp"` and `RequireSTARTTLS=false` (opportunistic for
port 25; make it required by publishing MTA-STS or DANE in dedicated
checkers). `checker-tls` picks up those entries and runs certificate
posture on the same connection our probe just validated; the resulting
`tls_probes` observations are folded back into our rule aggregation and
HTML report via `ObservationGetter.GetRelated` / `ReportContext.Related`,
so a bad certificate on an MX shows up on the SMTP service page, not
only in a separate TLS view.
## What it checks
### DNS posture
1. MX records published? (RFC 7505 null-MX is recognised and reported as INFO)
2. MX target is a hostname, **not** an IP literal (RFC 5321 § 5.1).
3. MX target is **not** a CNAME (RFC 5321 § 5.1).
4. MX target resolves (A and/or AAAA).
5. Implicit-MX fallback warned about.
### Per-endpoint (port 25, for each A/AAAA of each MX)
6. TCP reachability.
7. SMTP 220 banner, captured verbatim; announced hostname parsed.
8. ESMTP EHLO (fallback to HELO detected and flagged).
9. Extension inventory: STARTTLS, PIPELINING, 8BITMIME, SMTPUTF8,
CHUNKING, DSN, ENHANCEDSTATUSCODES, SIZE, AUTH.
10. `AUTH` advertised *before* STARTTLS (credentials-over-plaintext risk).
11. STARTTLS negotiation and TLS version/cipher recorded (no cert checks; handed off to `checker-tls`).
12. Post-TLS EHLO: extensions may expand after the upgrade; we union them.
13. Reverse DNS (PTR) present for each IP.
14. Forward-confirmed reverse DNS (FCrDNS): PTR's forward resolution must include our IP (Gmail / Outlook / Yahoo reject without this).
15. Null sender acceptance (`MAIL FROM:<>`; RFC 5321 mandates this for bounces).
16. Postmaster mailbox acceptance (`RCPT TO:<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).
## 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`.

519
checker/collect.go Normal file
View file

@ -0,0 +1,519 @@
package checker
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const defaultEHLOName = "mx-checker.happydomain.org"
const smtpPort = 25
// mxServiceBody mirrors the shape of svcs.MXs in happyDomain. We decode
// it by hand (rather than importing the happyDomain server) to keep the
// build surface small (checker-srv follows the same pattern).
type mxServiceBody struct {
MXs []struct {
Hdr struct {
Name string `json:"Name"`
} `json:"Hdr"`
Preference uint16 `json:"Preference"`
Mx string `json:"Mx"`
} `json:"mx"`
}
func (p *smtpProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("domain is required")
}
helo, _ := sdk.GetOption[string](opts, "helo_name")
helo = strings.TrimSpace(helo)
if helo == "" {
helo = defaultEHLOName
}
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 12)
if timeoutSecs < 1 {
timeoutSecs = 12
}
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
testNull := sdk.GetBoolOption(opts, "test_null_sender", true)
testPostmaster := sdk.GetBoolOption(opts, "test_postmaster", true)
testOpenRelay := sdk.GetBoolOption(opts, "test_open_relay", true)
probeRcpt, _ := sdk.GetOption[string](opts, "test_probe_address")
probeRcpt = strings.TrimSpace(probeRcpt)
if probeRcpt == "" {
probeRcpt = "postmaster@example.com"
}
// Never use a recipient inside the domain under test; that would turn
// an accept into a false-positive open relay.
if addrDomain, _, ok := splitMail(probeRcpt); ok && strings.EqualFold(addrDomain, domain) {
probeRcpt = "postmaster@example.com"
}
data := &SMTPData{
Domain: domain,
RunAt: time.Now().UTC().Format(time.RFC3339),
}
resolver := net.DefaultResolver
lookupCtx, cancel := context.WithTimeout(ctx, perEndpoint)
defer cancel()
// Prefer the service body when supplied (authoritative, already
// parsed from the zone); fall back to a live MX lookup.
var mxTargets []mxTargetRaw
if body, ok := sdk.GetOption[json.RawMessage](opts, "service"); ok && len(body) > 0 {
mxTargets = parseServiceBody(body)
}
if len(mxTargets) == 0 {
var err error
mxTargets, err = lookupMX(lookupCtx, resolver, domain)
if err != nil {
data.MX.Error = err.Error()
}
}
// RFC 7505 null MX sentinel.
if len(mxTargets) == 1 && (mxTargets[0].Target == "" || mxTargets[0].Target == ".") && mxTargets[0].Preference == 0 {
data.MX.NullMX = true
data.Issues = deriveIssues(data)
return data, nil
}
if len(mxTargets) == 0 && data.MX.Error == "" {
// Implicit MX (RFC 5321 § 5.1): fall back to the bare domain.
data.MX.ImplicitMX = true
mxTargets = []mxTargetRaw{{Preference: 0, Target: domain}}
}
for _, t := range mxTargets {
rec := MXRecord{
Preference: t.Preference,
Target: strings.TrimSuffix(t.Target, "."),
}
if rec.Target == "" {
continue
}
if ip := net.ParseIP(rec.Target); ip != nil {
rec.IsIPLiteral = true
}
// Detect CNAME (RFC 5321 § 5.1 forbids MX → CNAME).
if !rec.IsIPLiteral {
if cname, err := resolver.LookupCNAME(lookupCtx, rec.Target); err == nil {
canon := strings.TrimSuffix(cname, ".")
if canon != "" && !strings.EqualFold(canon, rec.Target) {
rec.IsCNAME = true
rec.CNAMEChain = []string{rec.Target, canon}
}
}
}
if rec.IsIPLiteral {
if ip := net.ParseIP(rec.Target); ip != nil {
if v4 := ip.To4(); v4 != nil {
rec.IPv4 = append(rec.IPv4, v4.String())
} else {
rec.IPv6 = append(rec.IPv6, ip.String())
}
}
} else {
ips, err := resolver.LookupIPAddr(lookupCtx, rec.Target)
if err != nil {
rec.ResolveError = err.Error()
}
for _, ip := range ips {
if v4 := ip.IP.To4(); v4 != nil {
rec.IPv4 = append(rec.IPv4, v4.String())
} else {
rec.IPv6 = append(rec.IPv6, ip.IP.String())
}
}
}
data.MX.Records = append(data.MX.Records, rec)
}
// Probe every (target, ip) pair.
for _, rec := range data.MX.Records {
for _, ip := range rec.IPv4 {
ep := probeEndpoint(ctx, probeInputs{
target: rec.Target,
ip: ip,
isV6: false,
domain: domain,
heloName: helo,
timeout: perEndpoint,
testNull: testNull,
testPostmaster: testPostmaster,
testOpenRelay: testOpenRelay,
openRelayRcpt: probeRcpt,
})
data.Endpoints = append(data.Endpoints, ep)
}
for _, ip := range rec.IPv6 {
ep := probeEndpoint(ctx, probeInputs{
target: rec.Target,
ip: ip,
isV6: true,
domain: domain,
heloName: helo,
timeout: perEndpoint,
testNull: testNull,
testPostmaster: testPostmaster,
testOpenRelay: testOpenRelay,
openRelayRcpt: probeRcpt,
})
data.Endpoints = append(data.Endpoints, ep)
}
}
computeCoverage(data)
data.Issues = deriveIssues(data)
return data, nil
}
type mxTargetRaw struct {
Preference uint16
Target string
}
// parseServiceBody extracts the MX list from a happyDomain svcs.MXs
// payload. Returns nil when the payload doesn't look like one; we fall
// back to a live DNS lookup in that case.
func parseServiceBody(raw json.RawMessage) []mxTargetRaw {
// happyDomain wraps the body in ServiceMessage{Type, Service:<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
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(in.timeout))
sc := newSMTPConn(conn, in.timeout)
// Read the banner (220).
code, text, _, err := sc.readResponse()
if err != nil {
ep.Error = "banner: " + err.Error()
return ep
}
ep.BannerReceived = true
ep.BannerCode = code
ep.BannerLine = strings.TrimSpace(strings.ReplaceAll(text, "\n", " | "))
ep.BannerHostname = parseBanner(text)
if code != 220 {
ep.Error = fmt.Sprintf("banner: unexpected code %d", code)
return ep
}
// EHLO (fall back to HELO on 5xx).
_, text, lines, err := sc.cmd("EHLO " + in.heloName)
if err != nil {
ep.Error = "ehlo: " + err.Error()
return ep
}
if lines[0][0] == '5' {
// Try HELO.
_, _, heloLines, herr := sc.cmd("HELO " + in.heloName)
if herr != nil || len(heloLines) == 0 || heloLines[0][0] != '2' {
ep.Error = "ehlo/helo both rejected"
return ep
}
ep.EHLOReceived = true
ep.EHLOFallbackHELO = true
ep.EHLOHostname = strings.TrimSpace(strings.SplitN(text, " ", 2)[0])
sc.close()
return ep
}
ep.EHLOReceived = true
greeting, exts := parseEHLO(lines)
ep.EHLOHostname = greeting
ep.Extensions = exts
idx := buildExtensions(exts)
ep.STARTTLSOffered = idx.has("STARTTLS")
ep.HasPipelining = idx.has("PIPELINING")
ep.Has8BITMIME = idx.has("8BITMIME")
ep.HasSMTPUTF8 = idx.has("SMTPUTF8")
ep.HasCHUNKING = idx.has("CHUNKING")
ep.HasDSN = idx.has("DSN")
ep.HasENHANCEDCODE = idx.has("ENHANCEDSTATUSCODES")
ep.SizeLimit = idx.parseSize()
ep.AUTHPreTLS = idx.parseAuth()
// STARTTLS.
if ep.STARTTLSOffered {
code, _, _, terr := sc.cmd("STARTTLS")
if terr == nil && code == 220 {
tlsConn := tls.Client(conn, tlsProbeConfig(in.target))
_ = tlsConn.SetDeadline(time.Now().Add(in.timeout))
if herr := tlsConn.Handshake(); herr != nil {
ep.Error = "tls-handshake: " + herr.Error()
return ep
}
ep.STARTTLSUpgraded = true
state := tlsConn.ConnectionState()
ep.TLSVersion = tls.VersionName(state.Version)
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
sc.swap(tlsConn)
// Re-EHLO over TLS (mandatory per RFC 3207).
_, _, lines2, eerr := sc.cmd("EHLO " + in.heloName)
if eerr == nil && len(lines2) > 0 && lines2[0][0] == '2' {
_, exts2 := parseEHLO(lines2)
ep.PostTLSExtensions = exts2
idx2 := buildExtensions(exts2)
ep.AUTHPostTLS = idx2.parseAuth()
// Union the feature flags: some servers only advertise
// 8BITMIME, PIPELINING, etc. after STARTTLS.
if !ep.HasPipelining {
ep.HasPipelining = idx2.has("PIPELINING")
}
if !ep.Has8BITMIME {
ep.Has8BITMIME = idx2.has("8BITMIME")
}
if !ep.HasSMTPUTF8 {
ep.HasSMTPUTF8 = idx2.has("SMTPUTF8")
}
if !ep.HasCHUNKING {
ep.HasCHUNKING = idx2.has("CHUNKING")
}
if !ep.HasDSN {
ep.HasDSN = idx2.has("DSN")
}
if !ep.HasENHANCEDCODE {
ep.HasENHANCEDCODE = idx2.has("ENHANCEDSTATUSCODES")
}
if ep.SizeLimit == 0 {
ep.SizeLimit = idx2.parseSize()
}
}
} else if terr != nil {
ep.Error = "starttls: " + terr.Error()
return ep
} else {
ep.Error = fmt.Sprintf("starttls: unexpected code %d", code)
// Don't bail; still run transactional probes over plaintext
// so the operator sees what the server does without TLS.
}
}
// RCPT-level probes. Each runs in its own MAIL/RSET pair so an earlier
// reject does not mask later ones.
runRCPT := func(from, to string) (int, string) {
code, text, _, err := sc.cmd("MAIL FROM:<" + from + ">")
if err != nil {
return -1, err.Error()
}
if code != 250 {
defer sc.cmd("RSET")
return code, strings.TrimSpace(text)
}
code, text, _, err = sc.cmd("RCPT TO:<" + to + ">")
sc.cmd("RSET")
if err != nil {
return -1, err.Error()
}
return code, strings.TrimSpace(text)
}
if in.testNull {
c, t := runRCPT("", "postmaster@"+in.domain)
ok := c >= 200 && c < 300
ep.NullSenderAccepted = &ok
ep.NullSenderResponse = fmt.Sprintf("%d %s", c, t)
}
if in.testPostmaster {
from := "checker@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org")
c, t := runRCPT(from, "postmaster@"+in.domain)
ok := c >= 200 && c < 300
ep.PostmasterAccepted = &ok
ep.PostmasterResponse = fmt.Sprintf("%d %s", c, t)
}
if in.testOpenRelay && in.openRelayRcpt != "" {
from := "checker@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org")
c, t := runRCPT(from, in.openRelayRcpt)
ok := c >= 200 && c < 300
ep.OpenRelay = &ok
ep.OpenRelayResponse = fmt.Sprintf("%d %s", c, t)
ep.OpenRelayRecipient = in.openRelayRcpt
}
sc.close()
return ep
}
func firstNonEmpty(a ...string) string {
for _, s := range a {
if s != "" {
return s
}
}
return ""
}
func splitMail(addr string) (domain, local string, ok bool) {
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return "", "", false
}
return addr[at+1:], addr[:at], true
}
func computeCoverage(data *SMTPData) {
if len(data.Endpoints) == 0 {
return
}
allSTARTTLS := true
allAcceptMail := true
for _, ep := range data.Endpoints {
if ep.TCPConnected {
data.Coverage.AnyReachable = true
if ep.IsIPv6 {
data.Coverage.HasIPv6 = true
} else {
data.Coverage.HasIPv4 = true
}
}
if ep.BannerReceived {
data.Coverage.AnyBanner = true
}
if ep.EHLOReceived {
data.Coverage.AnyEHLO = true
}
if ep.STARTTLSUpgraded {
data.Coverage.AnySTARTTLS = true
} else {
allSTARTTLS = false
}
// An endpoint "accepts mail" when the null-sender probe, if run,
// was accepted and the postmaster probe, if run, was accepted.
acc := true
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
acc = false
}
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
acc = false
}
if !ep.EHLOReceived {
acc = false
}
if !acc {
allAcceptMail = false
}
}
data.Coverage.AllSTARTTLS = allSTARTTLS
data.Coverage.AllAcceptMail = allAcceptMail
}

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 Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "smtp",
Name: "SMTP Server (MX)",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"svcs.MXs"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeySMTP},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "Domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 12,
},
{
Id: "helo_name",
Type: "string",
Label: "EHLO hostname",
Placeholder: "mx-checker.happydomain.org",
Default: "mx-checker.happydomain.org",
Description: "The hostname announced in EHLO/HELO. Use a name that resolves and has a valid PTR; some large providers tarpit or reject probes from unresolvable EHLO names.",
},
{
Id: "test_null_sender",
Type: "bool",
Label: "Probe null sender (MAIL FROM:<>)",
Default: true,
Description: "RFC 5321 mandates that bounces with an empty envelope sender are accepted; servers that reject <> cannot receive DSNs.",
},
{
Id: "test_postmaster",
Type: "bool",
Label: "Probe RCPT TO:<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: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
}

123
checker/interactive.go Normal file
View file

@ -0,0 +1,123 @@
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm implements sdk.CheckerInteractive: 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 sdk.CheckerInteractive: 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")
}
opts := sdk.CheckerOptions{
"domain": domain,
}
if helo := strings.TrimSpace(r.FormValue("helo_name")); helo != "" {
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 != "" {
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
}
}

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
}

77
checker/provider.go Normal file
View file

@ -0,0 +1,77 @@
package checker
import (
"net"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
func Provider() sdk.ObservationProvider {
return &smtpProvider{}
}
type smtpProvider struct{}
func (p *smtpProvider) Key() sdk.ObservationKey {
return ObservationKeySMTP
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *smtpProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// We publish one tls.endpoint.v1 entry per MX target so the TLS checker
// picks up the connection and runs the cert/chain/SAN/expiry posture
// against it. STARTTLS is "smtp" and RequireSTARTTLS stays false because
// MX SMTP on port 25 is opportunistic (RFC 7672 / RFC 8461 are what turn
// it into a hard requirement, and they live in separate checkers).
//
// SNI is the MX target hostname: that is the name the receiving MTA
// controls and will typically present in its certificate. RFC 7672
// DANE-TLSA binds the TLSA record to <_port._tcp.mx-target>, so the
// target's A/AAAA+name are also the right reference for DANE.
func (p *smtpProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*SMTPData)
if !ok || d == nil {
return nil, nil
}
if d.MX.NullMX {
return nil, nil
}
var out []sdk.DiscoveryEntry
seen := map[string]bool{}
for _, rec := range d.MX.Records {
if rec.Target == "" || rec.IsIPLiteral {
continue
}
key := endpointKey(rec.Target, smtpPort)
if seen[key] {
continue
}
seen[key] = true
ep := tlsct.TLSEndpoint{
Host: rec.Target,
Port: smtpPort,
SNI: rec.Target,
STARTTLS: "smtp",
RequireSTARTTLS: false,
}
entry, err := tlsct.NewEntry(ep)
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}
func endpointKey(host string, port uint16) string {
return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
}

614
checker/report.go Normal file
View file

@ -0,0 +1,614 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
Target string
}
type reportMX struct {
Preference uint16
Target string
IPv4 []string
IPv6 []string
IsCNAME bool
CNAMEChain []string
IsIPLiteral bool
ResolveErr string
}
type reportEndpoint struct {
Target string
Address string
IP string
IsIPv6 bool
StatusLabel string
StatusClass string
AnyFail bool
TCPConnected bool
BannerLine string
BannerHostname string
BannerCode int
EHLOReceived bool
EHLOFallbackHELO bool
EHLOHostname string
STARTTLSOffered bool
STARTTLSUpgraded bool
TLSVersion string
TLSCipher string
SizeLimit uint64
HasPipelining bool
Has8BITMIME bool
HasSMTPUTF8 bool
HasCHUNKING bool
HasDSN bool
HasENHANCEDCODE bool
AUTHPreTLS []string
AUTHPostTLS []string
PTR string
PTRError string
FCrDNSPass bool
NullSenderState string
NullSenderClass string
NullSenderResponse string
PostmasterState string
PostmasterClass string
PostmasterResponse string
OpenRelayState string
OpenRelayClass string
OpenRelayResponse string
OpenRelayRecipient string
ElapsedMS int64
Error string
// TLS posture (from a related tls_probes observation, when available).
TLSPosture *reportTLSPosture
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
Issues []reportFix
}
type reportData struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
MX []reportMX
NullMX bool
ImplicitMX bool
MXError string
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
AnySTARTTLS bool
AllSTARTTLS bool
HasTLSPosture bool
}
var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{
"deref": func(b *bool) bool { return b != nil && *b },
"humanBytes": func(n uint64) string {
if n == 0 {
return "no limit"
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f := float64(n)
u := 0
for f >= 1024 && u < len(units)-1 {
f /= 1024
u++
}
return fmt.Sprintf("%.1f %s", f, units[u])
},
}).Parse(`<!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))
return renderReport(view)
}
func renderReport(view reportData) (string, error) {
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render smtp report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *SMTPData, related []sdk.RelatedObservation) reportData {
tlsIssues := tlsIssuesFromRelated(related)
tlsByAddr := indexTLSByAddress(related)
allIssues := append([]Issue(nil), d.Issues...)
allIssues = append(allIssues, tlsIssues...)
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
NullMX: d.MX.NullMX,
ImplicitMX: d.MX.ImplicitMX,
MXError: d.MX.Error,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
AnySTARTTLS: d.Coverage.AnySTARTTLS,
AllSTARTTLS: d.Coverage.AllSTARTTLS,
HasIssues: len(allIssues) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
worst := SeverityInfo
for _, is := range allIssues {
if is.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if is.Severity == SeverityWarn {
worst = SeverityWarn
}
}
if d.MX.NullMX {
view.StatusLabel = "NULL MX"
view.StatusClass = "info"
} else if len(allIssues) == 0 {
view.StatusLabel = "OK"
view.StatusClass = "ok"
} else {
switch worst {
case SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "info"
}
}
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
for _, is := range allIssues {
view.Fixes = append(view.Fixes, reportFix{
Severity: is.Severity,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
Endpoint: is.Endpoint,
Target: is.Target,
})
}
for _, rec := range d.MX.Records {
view.MX = append(view.MX, reportMX{
Preference: rec.Preference,
Target: rec.Target,
IPv4: rec.IPv4,
IPv6: rec.IPv6,
IsCNAME: rec.IsCNAME,
CNAMEChain: rec.CNAMEChain,
IsIPLiteral: rec.IsIPLiteral,
ResolveErr: rec.ResolveError,
})
}
for _, ep := range d.Endpoints {
re := reportEndpoint{
Target: ep.Target,
Address: ep.Address,
IP: ep.IP,
IsIPv6: ep.IsIPv6,
TCPConnected: ep.TCPConnected,
BannerLine: ep.BannerLine,
BannerHostname: ep.BannerHostname,
BannerCode: ep.BannerCode,
EHLOReceived: ep.EHLOReceived,
EHLOFallbackHELO: ep.EHLOFallbackHELO,
EHLOHostname: ep.EHLOHostname,
STARTTLSOffered: ep.STARTTLSOffered,
STARTTLSUpgraded: ep.STARTTLSUpgraded,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
SizeLimit: ep.SizeLimit,
HasPipelining: ep.HasPipelining,
Has8BITMIME: ep.Has8BITMIME,
HasSMTPUTF8: ep.HasSMTPUTF8,
HasCHUNKING: ep.HasCHUNKING,
HasDSN: ep.HasDSN,
HasENHANCEDCODE: ep.HasENHANCEDCODE,
AUTHPreTLS: ep.AUTHPreTLS,
AUTHPostTLS: ep.AUTHPostTLS,
PTR: ep.PTR,
PTRError: ep.PTRError,
FCrDNSPass: ep.FCrDNSPass,
NullSenderResponse: ep.NullSenderResponse,
PostmasterResponse: ep.PostmasterResponse,
OpenRelayResponse: ep.OpenRelayResponse,
OpenRelayRecipient: ep.OpenRelayRecipient,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
}
if ep.NullSenderAccepted != nil {
if *ep.NullSenderAccepted {
re.NullSenderState = "accepted"
re.NullSenderClass = "ok"
} else {
re.NullSenderState = "REJECTED"
re.NullSenderClass = "fail"
}
}
if ep.PostmasterAccepted != nil {
if *ep.PostmasterAccepted {
re.PostmasterState = "accepted"
re.PostmasterClass = "ok"
} else {
re.PostmasterState = "REJECTED"
re.PostmasterClass = "fail"
}
}
if ep.OpenRelay != nil {
if *ep.OpenRelay {
re.OpenRelayState = "OPEN RELAY"
re.OpenRelayClass = "fail"
} else {
re.OpenRelayState = "properly refused"
re.OpenRelayClass = "ok"
}
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
re.TLSPosture = meta
}
ok := ep.TCPConnected && ep.EHLOReceived
if ep.STARTTLSOffered {
ok = ok && ep.STARTTLSUpgraded
}
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
ok = false
}
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
ok = false
}
if ep.OpenRelay != nil && *ep.OpenRelay {
ok = false
}
re.AnyFail = !ok
switch {
case !ep.TCPConnected:
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
case ep.OpenRelay != nil && *ep.OpenRelay:
re.StatusLabel = "OPEN RELAY"
re.StatusClass = "fail"
case !ok:
re.StatusLabel = "partial"
re.StatusClass = "warn"
default:
re.StatusLabel = "OK"
re.StatusClass = "ok"
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
out := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if addr == "" {
continue
}
posture := &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
}
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
continue
}
posture.Issues = append(posture.Issues, reportFix{
Severity: sev,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
})
}
out[addr] = posture
}
return out
}

103
checker/rule.go Normal file
View file

@ -0,0 +1,103 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Rule() sdk.CheckRule {
return &smtpRule{}
}
type smtpRule struct{}
func (r *smtpRule) Name() string {
return "smtp_server"
}
func (r *smtpRule) Description() string {
return "Checks MX discovery, SMTP connectivity, STARTTLS, PTR/FCrDNS and mail-acceptance posture for an email-receiving domain"
}
func (r *smtpRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data SMTPData
if err := obs.Get(ctx, ObservationKeySMTP, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SMTP observation: %v", err),
Code: "smtp.observation_error",
}}
}
// Null-MX is a *declared* refusal of mail; it's an INFO, not a fail.
if data.MX.NullMX {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Message: "Domain refuses all email via null MX (RFC 7505).",
Code: CodeNullMX,
Meta: map[string]any{
"null_mx": true,
},
}}
}
issues := append([]Issue(nil), data.Issues...)
// Fold related TLS observations so cert/chain problems show up on the
// SMTP service page without requiring the user to open a separate
// report.
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
issues = append(issues, tlsIssuesFromRelated(related)...)
if len(issues) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Message: fmt.Sprintf("SMTP operational (%d MX, %d endpoints, TLS=%v)", len(data.MX.Records), len(data.Endpoints), data.Coverage.AllSTARTTLS),
Code: "smtp.ok",
Meta: map[string]any{
"has_ipv4": data.Coverage.HasIPv4,
"has_ipv6": data.Coverage.HasIPv6,
"any_starttls": data.Coverage.AnySTARTTLS,
"all_starttls": data.Coverage.AllSTARTTLS,
"all_accept_mail": data.Coverage.AllAcceptMail,
"mx_count": len(data.MX.Records),
"endpoint_count": len(data.Endpoints),
},
}}
}
out := make([]sdk.CheckState, 0, len(issues))
for _, is := range issues {
status := sdk.StatusInfo
switch is.Severity {
case SeverityCrit:
status = sdk.StatusCrit
case SeverityWarn:
status = sdk.StatusWarn
}
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
}
out = append(out, sdk.CheckState{
Status: status,
Message: is.Message,
Code: is.Code,
Subject: subject,
Meta: meta,
})
}
return out
}

227
checker/smtp.go Normal file
View file

@ -0,0 +1,227 @@
package checker
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
)
// smtpConn is a minimal ESMTP client focused on *observation*, not on
// transmitting mail: we never reach DATA, and every transaction is torn
// down with RSET + QUIT. Writing our own client (instead of using
// net/smtp) gives us access to the raw banner text, per-line extension
// responses, and the timing of each step.
type smtpConn struct {
raw net.Conn
br *bufio.Reader
wr io.Writer
timeout time.Duration
}
func newSMTPConn(c net.Conn, timeout time.Duration) *smtpConn {
return &smtpConn{
raw: c,
br: bufio.NewReader(c),
wr: c,
timeout: timeout,
}
}
// touch extends the per-turn deadline on the underlying connection.
func (s *smtpConn) touch() {
if s.timeout > 0 {
_ = s.raw.SetDeadline(time.Now().Add(s.timeout))
}
}
// swap replaces the underlying connection (used after STARTTLS).
func (s *smtpConn) swap(c net.Conn) {
s.raw = c
s.br = bufio.NewReader(c)
s.wr = c
s.touch()
}
// writeLine writes a single CRLF-terminated line.
func (s *smtpConn) writeLine(line string) error {
s.touch()
_, err := io.WriteString(s.wr, line+"\r\n")
return err
}
// readResponse reads a multiline SMTP response ("xxx-...\r\nxxx ...\r\n")
// and returns (code, full-joined-text, raw-lines, error).
//
// Any transport error is surfaced immediately; a malformed line (missing
// 3-digit code, missing separator) yields an error with the offending
// text so callers can surface it verbatim.
func (s *smtpConn) readResponse() (code int, text string, lines []string, err error) {
for {
s.touch()
line, rerr := s.br.ReadString('\n')
if rerr != nil && line == "" {
return 0, "", lines, rerr
}
line = strings.TrimRight(line, "\r\n")
if len(line) < 4 {
return 0, line, append(lines, line), fmt.Errorf("short SMTP line %q", line)
}
cStr := line[:3]
sep := line[3]
rest := line[4:]
c, nerr := strconv.Atoi(cStr)
if nerr != nil {
return 0, line, append(lines, line), fmt.Errorf("bad SMTP code in %q", line)
}
if code == 0 {
code = c
}
lines = append(lines, line)
if sep == ' ' {
text += rest
return code, text, lines, nil
}
if sep == '-' {
text += rest + "\n"
continue
}
return 0, line, lines, fmt.Errorf("bad separator %q in %q", sep, line)
}
}
// cmd writes a command and returns the server response.
func (s *smtpConn) cmd(line string) (int, string, []string, error) {
if err := s.writeLine(line); err != nil {
return 0, "", nil, err
}
return s.readResponse()
}
// close attempts a graceful QUIT then closes the socket. Errors are
// swallowed; the caller has already captured everything interesting.
func (s *smtpConn) close() {
_ = s.writeLine("QUIT")
_, _, _, _ = s.readResponse()
_ = s.raw.Close()
}
// parseBanner teases the announced hostname out of the 220 greeting.
// ESMTP convention is "220 <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 {
fields := strings.Fields(text)
for _, f := range fields {
// Skip things that are obviously not a hostname.
if strings.Contains(f, "@") {
continue
}
if !strings.Contains(f, ".") {
continue
}
// Strip trailing punctuation.
f = strings.TrimRight(f, ",;:.")
if looksLikeHostname(f) {
return f
}
}
return ""
}
func looksLikeHostname(s string) bool {
if s == "" || len(s) > 253 {
return false
}
// A hostname has at least one dot and no invalid characters.
if strings.ContainsAny(s, " \t\r\n<>\"()[]") {
return false
}
for _, r := range s {
if r == '.' || r == '-' || r == '_' ||
(r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
return false
}
return true
}
// parseEHLO splits an EHLO response into its keyword/arg lines. The
// first line is the greeting hostname, subsequent lines are extensions.
func parseEHLO(lines []string) (greeting string, extensions []string) {
for i, l := range lines {
if len(l) < 4 {
continue
}
payload := strings.TrimSpace(l[4:])
if i == 0 {
greeting = payload
continue
}
extensions = append(extensions, payload)
}
return greeting, extensions
}
// extensionLookup indexes parsed EHLO extensions by their (uppercased)
// keyword, preserving the argument portion unchanged.
type extensionLookup map[string]string
func buildExtensions(exts []string) extensionLookup {
m := extensionLookup{}
for _, e := range exts {
kw, arg, _ := strings.Cut(e, " ")
m[strings.ToUpper(strings.TrimSpace(kw))] = strings.TrimSpace(arg)
}
return m
}
func (m extensionLookup) has(kw string) bool {
_, ok := m[kw]
return ok
}
// parseSize extracts the integer argument of the SIZE extension (0 when
// absent or unparseable). SIZE may be advertised without an argument;
// we treat that as "no limit declared".
func (m extensionLookup) parseSize() uint64 {
v, ok := m["SIZE"]
if !ok || v == "" {
return 0
}
n, err := strconv.ParseUint(strings.Fields(v)[0], 10, 64)
if err != nil {
return 0
}
return n
}
// parseAuth returns the SASL mechanisms advertised under the AUTH
// extension, upper-cased for easy comparison. Empty slice when AUTH is
// not advertised.
func (m extensionLookup) parseAuth() []string {
v, ok := m["AUTH"]
if !ok || v == "" {
return nil
}
out := strings.Fields(v)
for i := range out {
out[i] = strings.ToUpper(out[i])
}
return out
}
// tlsProbeConfig mirrors the XMPP checker's stance: certificate
// verification is checker-tls' job, so we skip it here.
func tlsProbeConfig(serverName string) *tls.Config {
return &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec (cert validation is the TLS checker's job)
MinVersion: tls.VersionTLS10,
}
}

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
}

189
checker/types.go Normal file
View file

@ -0,0 +1,189 @@
// Package checker implements the SMTP (MX) server checker for happyDomain.
//
// It probes a domain's inbound-mail deployment (MX discovery, TCP
// reachability, ESMTP banner & EHLO, STARTTLS negotiation, open-relay
// posture, RFC 5321 § 4.5.1 postmaster acceptance, PTR / FCrDNS) and
// reports actionable findings.
//
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
// out of scope: we publish each MX target as a DiscoveryEntry of type
// tls.endpoint.v1 with STARTTLS="smtp" so checker-tls picks up the
// connection and runs the TLS posture checks itself. The resulting
// observations flow back into our rule and HTML report via the SDK's
// ObservationGetter.GetRelated / ReportContext.Related path.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeySMTP is the key under which this checker's observation
// payload is stored.
const ObservationKeySMTP sdk.ObservationKey = "smtp"
// SMTPData is the full observation stored per run.
type SMTPData struct {
Domain string `json:"domain"`
RunAt string `json:"run_at"`
MX MXLookup `json:"mx"`
Endpoints []EndpointProbe `json:"endpoints"`
Coverage Coverage `json:"coverage"`
Issues []Issue `json:"issues"`
}
// MXLookup captures the MX discovery step.
type MXLookup struct {
Records []MXRecord `json:"records,omitempty"`
// Error is a non-NXDOMAIN DNS failure (servfail, timeout, …).
Error string `json:"error,omitempty"`
// NullMX is true when the sole record is "." with preference 0 (RFC 7505).
NullMX bool `json:"null_mx,omitempty"`
// ImplicitMX is true when no MX was published and we fell back to the
// A/AAAA of the domain itself (RFC 5321 § 5.1, implicit MX).
ImplicitMX bool `json:"implicit_mx,omitempty"`
}
// MXRecord is a single MX RR target, expanded with per-target DNS checks.
type MXRecord struct {
Preference uint16 `json:"preference"`
Target string `json:"target"`
// Resolution.
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
// Error from A/AAAA resolution (empty string when OK).
ResolveError string `json:"resolve_error,omitempty"`
// IsCNAME flags targets pointed at via a CNAME chain, forbidden by
// RFC 5321 § 5.1 ("the domain name that appears in the RDATA SHOULD
// have an associated address record"): senders MAY reject or fail.
IsCNAME bool `json:"is_cname,omitempty"`
CNAMEChain []string `json:"cname_chain,omitempty"`
// IsIPLiteral flags targets that look like an IP address instead of a
// hostname (RFC 5321 § 5.1 forbids it).
IsIPLiteral bool `json:"is_ip_literal,omitempty"`
}
// EndpointProbe is the result of probing one (target, ip, port=25) tuple.
type EndpointProbe struct {
Target string `json:"target"`
Port uint16 `json:"port"`
IP string `json:"ip"`
IsIPv6 bool `json:"is_ipv6,omitempty"`
// Address is "ip:port"; used as a stable key.
Address string `json:"address"`
// Timing & errors.
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
// Connection stages.
TCPConnected bool `json:"tcp_connected"`
BannerReceived bool `json:"banner_received"`
BannerLine string `json:"banner_line,omitempty"`
BannerHostname string `json:"banner_hostname,omitempty"`
BannerCode int `json:"banner_code,omitempty"`
EHLOReceived bool `json:"ehlo_received"`
EHLOHostname string `json:"ehlo_hostname,omitempty"`
EHLOFallbackHELO bool `json:"ehlo_fallback_helo,omitempty"`
// Pre-TLS extensions.
Extensions []string `json:"extensions,omitempty"`
STARTTLSOffered bool `json:"starttls_offered"`
AUTHPreTLS []string `json:"auth_pre_tls,omitempty"`
SizeLimit uint64 `json:"size_limit,omitempty"`
HasPipelining bool `json:"has_pipelining,omitempty"`
Has8BITMIME bool `json:"has_8bitmime,omitempty"`
HasSMTPUTF8 bool `json:"has_smtputf8,omitempty"`
HasCHUNKING bool `json:"has_chunking,omitempty"`
HasDSN bool `json:"has_dsn,omitempty"`
HasENHANCEDCODE bool `json:"has_enhancedstatuscodes,omitempty"`
// STARTTLS negotiation.
STARTTLSUpgraded bool `json:"starttls_upgraded,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
// Post-TLS extensions (typically identical or larger than pre-TLS).
PostTLSExtensions []string `json:"post_tls_extensions,omitempty"`
AUTHPostTLS []string `json:"auth_post_tls,omitempty"`
// Reverse DNS / FCrDNS.
PTR string `json:"ptr,omitempty"`
PTRError string `json:"ptr_error,omitempty"`
FCrDNSPass bool `json:"fcrdns_pass,omitempty"`
// Mail transaction probes.
NullSenderAccepted *bool `json:"null_sender_accepted,omitempty"`
NullSenderResponse string `json:"null_sender_response,omitempty"`
PostmasterAccepted *bool `json:"postmaster_accepted,omitempty"`
PostmasterResponse string `json:"postmaster_response,omitempty"`
OpenRelay *bool `json:"open_relay,omitempty"`
OpenRelayResponse string `json:"open_relay_response,omitempty"`
OpenRelayRecipient string `json:"open_relay_recipient,omitempty"`
}
// Coverage summarises which axes are working at the domain level.
type Coverage struct {
HasIPv4 bool `json:"has_ipv4"`
HasIPv6 bool `json:"has_ipv6"`
AnyReachable bool `json:"any_reachable"`
AnyBanner bool `json:"any_banner"`
AnyEHLO bool `json:"any_ehlo"`
AnySTARTTLS bool `json:"any_starttls"`
AllSTARTTLS bool `json:"all_starttls"`
AllAcceptMail bool `json:"all_accept_mail"`
}
// Issue is a structured finding, consumed by both the rule and the HTML report.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"` // "info" | "warn" | "crit"
Message string `json:"message"`
Fix string `json:"fix,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Target string `json:"target,omitempty"`
}
// Severities (string for stable JSON, independent of sdk.Status numeric values).
const (
SeverityInfo = "info"
SeverityWarn = "warn"
SeverityCrit = "crit"
)
// Issue codes.
const (
CodeNoMX = "smtp.no_mx"
CodeMXLookupFailed = "smtp.mx.lookup_failed"
CodeNullMX = "smtp.null_mx"
CodeImplicitMX = "smtp.mx.implicit"
CodeMXCNAME = "smtp.mx.cname"
CodeMXIPLiteral = "smtp.mx.ip_literal"
CodeMXResolveFailed = "smtp.mx.resolve_failed"
CodeNoAddresses = "smtp.mx.no_addresses"
CodeTCPUnreachable = "smtp.tcp.unreachable"
CodeBannerMissing = "smtp.banner.missing"
CodeBannerInvalid = "smtp.banner.invalid"
CodeEHLOFailed = "smtp.ehlo.failed"
CodeEHLOFallback = "smtp.ehlo.fallback_helo"
CodeSTARTTLSMissing = "smtp.starttls.missing"
CodeSTARTTLSFailed = "smtp.starttls.failed"
CodeAUTHOverPlain = "smtp.auth.plaintext"
CodePTRMissing = "smtp.ptr.missing"
CodeFCrDNSMismatch = "smtp.fcrdns.mismatch"
CodeNullSenderReject = "smtp.null_sender.rejected"
CodePostmasterReject = "smtp.postmaster.rejected"
CodeOpenRelay = "smtp.open_relay"
CodeNoPipelining = "smtp.no_pipelining"
CodeNo8BITMIME = "smtp.no_8bitmime"
CodeNoIPv6 = "smtp.no_ipv6"
CodeAllEndpointsDown = "smtp.all_endpoints_down"
CodeAllNoSTARTTLS = "smtp.all_no_starttls"
)

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.1.0
git.happydns.org/checker-tls v0.1.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)

20
go.sum Normal file
View file

@ -0,0 +1,20 @@
git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.1.0 h1:eFHn9B5GKfc/lKdRRgOu+gf5FiMqwna43ftIXTbBLhc=
git.happydns.org/checker-tls v0.1.0/go.mod h1:dJR9pIejNQU6TR0R0Ik4bXX77+q1jVeOVvaRM5iYZt8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

27
main.go Normal file
View file

@ -0,0 +1,27 @@
package main
import (
"flag"
"log"
sdk "git.happydns.org/checker-sdk-go/checker"
smtp "git.happydns.org/checker-smtp/checker"
)
// Version is the standalone binary's version. Overridden at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
smtp.Version = Version
server := sdk.NewServer(smtp.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

19
plugin/plugin.go Normal file
View file

@ -0,0 +1,19 @@
// Command plugin is the happyDomain plugin entrypoint for the SMTP checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
sdk "git.happydns.org/checker-sdk-go/checker"
smtp "git.happydns.org/checker-smtp/checker"
)
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
smtp.Version = Version
return smtp.Definition(), smtp.Provider(), nil
}