Initial commit
This commit is contained in:
commit
485c5a4a1d
33 changed files with 5407 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-smtp
|
||||
checker-smtp.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
29
NOTICE
Normal 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
190
README.md
Normal 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
563
checker/collect.go
Normal 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
307
checker/collect_test.go
Normal 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
85
checker/definition.go
Normal 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
134
checker/interactive.go
Normal 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
128
checker/interactive_test.go
Normal 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
288
checker/issues.go
Normal 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
326
checker/issues_test.go
Normal 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
370
checker/probe_test.go
Normal 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
72
checker/provider.go
Normal 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
101
checker/provider_test.go
Normal 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
663
checker/report.go
Normal 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">→ {{.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">✓ connected</span>{{else}}<span class="check-fail">✗ 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">✗ EHLO rejected, only HELO works</span>
|
||||
{{else if .EHLOReceived}}<span class="check-ok">✓ accepted{{if .EHLOHostname}} (<code>{{.EHLOHostname}}</code>){{end}}</span>
|
||||
{{else}}<span class="check-fail">✗ 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">✗ 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">✓ {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}}</span>
|
||||
{{else if .STARTTLSOffered}}<span class="check-fail">✗ handshake failed</span>
|
||||
{{else}}<span class="check-fail">✗ not offered</span>{{end}}
|
||||
</dd>
|
||||
{{with .TLSPosture}}
|
||||
<dt>TLS cert</dt><dd>
|
||||
{{if .ChainValid}}{{if deref .ChainValid}}<span class="check-ok">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}{{end}}
|
||||
{{if .HostnameMatch}} · {{if deref .HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}{{end}}
|
||||
{{if not .NotAfter.IsZero}} · 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">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>PTR</dt><dd>
|
||||
{{if .PTR}}<code>{{.PTR}}</code>
|
||||
{{if .FCrDNSPass}}· <span class="check-ok">✓ FCrDNS</span>
|
||||
{{else}}· <span class="check-fail">✗ FCrDNS mismatch</span>{{end}}
|
||||
{{else}}<span class="check-fail">✗ 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
240
checker/report_test.go
Normal 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
214
checker/rule.go
Normal 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
294
checker/rule_test.go
Normal 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
33
checker/rules_null_mx.go
Normal 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
39
checker/rules_simple.go
Normal 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)
|
||||
}
|
||||
36
checker/rules_tls_quality.go
Normal file
36
checker/rules_tls_quality.go
Normal 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
230
checker/smtp.go
Normal 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
192
checker/smtp_extra_test.go
Normal 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
177
checker/smtp_test.go
Normal 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
158
checker/tls_related.go
Normal 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
200
checker/tls_related_test.go
Normal 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
188
checker/types.go
Normal 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
17
go.mod
Normal 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
18
go.sum
Normal 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
27
main.go
Normal 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
20
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue