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