Initial commit
This commit is contained in:
commit
8f41185302
20 changed files with 2775 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-smtp
|
||||
checker-smtp.so
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-smtp .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-smtp /checker-smtp
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-smtp"]
|
||||
21
LICENSE
Normal file
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 -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
29
NOTICE
Normal file
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
|
||||
139
README.md
Normal file
139
README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# checker-smtp
|
||||
|
||||
Deep SMTP checker for the MX-based inbound mail service of a
|
||||
[happyDomain](https://www.happydomain.org/) domain.
|
||||
|
||||
For every MX target of the zone, it performs the live probes a human
|
||||
operator would run with `swaks` or `telnet … 25`: TCP connect, ESMTP
|
||||
banner & EHLO, STARTTLS negotiation, mail-transaction (null sender,
|
||||
postmaster, open-relay) probes, reverse DNS / FCrDNS, extension
|
||||
inventory, and IPv4/IPv6 coverage. The result is an actionable HTML
|
||||
report whose "What to fix" panel foregrounds the most common real-world
|
||||
failures rather than burying them in endpoint tabs.
|
||||
|
||||
TLS certificate chain / SAN / expiry / cipher posture is **out of scope**:
|
||||
a dedicated TLS checker handles that. This checker only confirms STARTTLS
|
||||
completes and records the negotiated TLS version/cipher for context.
|
||||
|
||||
We publish each MX target as a `DiscoveryEntry` of type
|
||||
`tls.endpoint.v1` (contract: `git.happydns.org/checker-tls/contract`)
|
||||
with `STARTTLS="smtp"` and `RequireSTARTTLS=false` (opportunistic for
|
||||
port 25; make it required by publishing MTA-STS or DANE in dedicated
|
||||
checkers). `checker-tls` picks up those entries and runs certificate
|
||||
posture on the same connection our probe just validated; the resulting
|
||||
`tls_probes` observations are folded back into our rule aggregation and
|
||||
HTML report via `ObservationGetter.GetRelated` / `ReportContext.Related`,
|
||||
so a bad certificate on an MX shows up on the SMTP service page, not
|
||||
only in a separate TLS view.
|
||||
|
||||
## What it checks
|
||||
|
||||
### DNS posture
|
||||
|
||||
1. MX records published? (RFC 7505 null-MX is recognised and reported as INFO)
|
||||
2. MX target is a hostname, **not** an IP literal (RFC 5321 § 5.1).
|
||||
3. MX target is **not** a CNAME (RFC 5321 § 5.1).
|
||||
4. MX target resolves (A and/or AAAA).
|
||||
5. Implicit-MX fallback warned about.
|
||||
|
||||
### Per-endpoint (port 25, for each A/AAAA of each MX)
|
||||
|
||||
6. TCP reachability.
|
||||
7. SMTP 220 banner, captured verbatim; announced hostname parsed.
|
||||
8. ESMTP EHLO (fallback to HELO detected and flagged).
|
||||
9. Extension inventory: STARTTLS, PIPELINING, 8BITMIME, SMTPUTF8,
|
||||
CHUNKING, DSN, ENHANCEDSTATUSCODES, SIZE, AUTH.
|
||||
10. `AUTH` advertised *before* STARTTLS (credentials-over-plaintext risk).
|
||||
11. STARTTLS negotiation and TLS version/cipher recorded (no cert checks; handed off to `checker-tls`).
|
||||
12. Post-TLS EHLO: extensions may expand after the upgrade; we union them.
|
||||
13. Reverse DNS (PTR) present for each IP.
|
||||
14. Forward-confirmed reverse DNS (FCrDNS): PTR's forward resolution must include our IP (Gmail / Outlook / Yahoo reject without this).
|
||||
15. Null sender acceptance (`MAIL FROM:<>`; RFC 5321 mandates this for bounces).
|
||||
16. Postmaster mailbox acceptance (`RCPT TO:<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 aggregates all of the above into a single `CheckState`
|
||||
(OK / WARN / CRIT / INFO), with the worst severity winning. The HTML
|
||||
report renders a domain-level "What to fix" panel (sorted
|
||||
crit → warn → info) plus one collapsible section per probed endpoint,
|
||||
open by default when something is wrong.
|
||||
|
||||
## Most common failures and how the report addresses them
|
||||
|
||||
| Symptom | Issue code | Report message |
|
||||
|-------------------------------------------|-----------------------------|----------------|
|
||||
| MX target is a CNAME | `smtp.mx.cname` | CRIT, fix suggests replacing CNAME with A/AAAA |
|
||||
| No STARTTLS on any endpoint | `smtp.all_no_starttls` | CRIT, fix mentions Postfix/Exim settings and MTA-STS/DANE next steps |
|
||||
| `AUTH` advertised over plaintext port 25 | `smtp.auth.plaintext` | CRIT, fix suggests `smtpd_tls_auth_only=yes` / moving auth to 587 |
|
||||
| `postmaster@` rejected | `smtp.postmaster.rejected` | CRIT, cites RFC 5321 § 4.5.1 |
|
||||
| Bounces (`MAIL FROM:<>`) rejected | `smtp.null_sender.rejected` | CRIT |
|
||||
| Missing PTR or FCrDNS mismatch | `smtp.ptr.missing`, `smtp.fcrdns.mismatch` | WARN, names Gmail/Outlook/Yahoo impact |
|
||||
| Open relay | `smtp.open_relay` | CRIT (the endpoint panel also shows a red "OPEN RELAY" badge in the summary) |
|
||||
|
||||
## Usage
|
||||
|
||||
### Standalone HTTP server
|
||||
|
||||
```bash
|
||||
make
|
||||
./checker-smtp -listen :8080
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
make docker
|
||||
docker run -p 8080:8080 happydomain/checker-smtp
|
||||
```
|
||||
|
||||
### happyDomain plugin
|
||||
|
||||
```bash
|
||||
make plugin
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Scope | Id | Default | Description |
|
||||
|-------|-----------------------|--------------------------------|-------------|
|
||||
| Run | `domain` | (none) | Domain to test (auto-filled from the service). |
|
||||
| Run | `timeout` | `12` | Per-endpoint timeout, in seconds. |
|
||||
| Run | `helo_name` | `mx-checker.happydomain.org` | Hostname announced in EHLO/HELO. Pick a name with valid A/AAAA and PTR. |
|
||||
| Run | `test_null_sender` | `true` | Probe `MAIL FROM:<>` (RFC 5321 DSN acceptance). |
|
||||
| Run | `test_postmaster` | `true` | Probe `RCPT TO:<postmaster@domain>` (RFC 5321 § 4.5.1). |
|
||||
| Run | `test_open_relay` | `true` | Probe `RCPT TO:<recipient-outside-domain>` to detect open relays. |
|
||||
| Run | `test_probe_address` | `postmaster@example.com` | Recipient used for the open-relay probe. Automatically overridden when equal to the tested domain. |
|
||||
|
||||
Applies to services of type `svcs.MXs` (the DNS-level MX record set).
|
||||
|
||||
## Design notes
|
||||
|
||||
- **Why not `net/smtp`?** The standard library's client hides the banner
|
||||
text, muxes multiline responses into a single string, and does not
|
||||
expose the pre- vs post-TLS extension set separately. A bespoke
|
||||
~200-line SMTP client (see `checker/smtp.go`) gives us verbatim
|
||||
responses for every step, which is what operators want to see in a
|
||||
diagnostic report.
|
||||
- **Why stop at RCPT?** The open-relay, null-sender and postmaster
|
||||
probes all end at RCPT and emit RSET before the next transaction. We
|
||||
never send `DATA`, so no mail is actually delivered and no bounces are
|
||||
generated. A receiving server that accepts a spoofed RCPT but would
|
||||
have rejected the message at DATA is still reported as open relay (a
|
||||
sensible choice for a posture check).
|
||||
- **Certificate posture via `checker-tls`.** MX SMTP on port 25 is
|
||||
opportunistic, so we do not verify the certificate ourselves. Each
|
||||
probed MX target is published as a `tls.endpoint.v1` discovery entry
|
||||
with `STARTTLS="smtp"`. `checker-tls`'s resulting observations are
|
||||
folded back into the rule aggregation and the HTML report via the
|
||||
SDK's `GetRelated` / `ReportContext.Related` path (same pattern as
|
||||
`checker-xmpp`).
|
||||
- **No DANE / MTA-STS checks here.** These are policy surfaces, not
|
||||
connection-time behaviours, and deserve their own checkers
|
||||
(`checker-dane` on TLSA records, `checker-mta-sts` on the TXT/HTTPS
|
||||
policy artefact). This checker answers the question "does the MX
|
||||
actually work?"; policy enforcement layers on top.
|
||||
|
||||
## License
|
||||
|
||||
MIT (see `LICENSE`). Third-party attributions in `NOTICE`.
|
||||
519
checker/collect.go
Normal file
519
checker/collect.go
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
const defaultEHLOName = "mx-checker.happydomain.org"
|
||||
const smtpPort = 25
|
||||
|
||||
// mxServiceBody mirrors the shape of svcs.MXs in happyDomain. We decode
|
||||
// it by hand (rather than importing the happyDomain server) to keep the
|
||||
// build surface small (checker-srv follows the same pattern).
|
||||
type mxServiceBody struct {
|
||||
MXs []struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Hdr"`
|
||||
Preference uint16 `json:"Preference"`
|
||||
Mx string `json:"Mx"`
|
||||
} `json:"mx"`
|
||||
}
|
||||
|
||||
func (p *smtpProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain")
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
|
||||
helo, _ := sdk.GetOption[string](opts, "helo_name")
|
||||
helo = strings.TrimSpace(helo)
|
||||
if helo == "" {
|
||||
helo = defaultEHLOName
|
||||
}
|
||||
|
||||
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 12)
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 12
|
||||
}
|
||||
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
|
||||
|
||||
testNull := sdk.GetBoolOption(opts, "test_null_sender", true)
|
||||
testPostmaster := sdk.GetBoolOption(opts, "test_postmaster", true)
|
||||
testOpenRelay := sdk.GetBoolOption(opts, "test_open_relay", true)
|
||||
probeRcpt, _ := sdk.GetOption[string](opts, "test_probe_address")
|
||||
probeRcpt = strings.TrimSpace(probeRcpt)
|
||||
if probeRcpt == "" {
|
||||
probeRcpt = "postmaster@example.com"
|
||||
}
|
||||
// Never use a recipient inside the domain under test; that would turn
|
||||
// an accept into a false-positive open relay.
|
||||
if addrDomain, _, ok := splitMail(probeRcpt); ok && strings.EqualFold(addrDomain, domain) {
|
||||
probeRcpt = "postmaster@example.com"
|
||||
}
|
||||
|
||||
data := &SMTPData{
|
||||
Domain: domain,
|
||||
RunAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, perEndpoint)
|
||||
defer cancel()
|
||||
|
||||
// Prefer the service body when supplied (authoritative, already
|
||||
// parsed from the zone); fall back to a live MX lookup.
|
||||
var mxTargets []mxTargetRaw
|
||||
if body, ok := sdk.GetOption[json.RawMessage](opts, "service"); ok && len(body) > 0 {
|
||||
mxTargets = parseServiceBody(body)
|
||||
}
|
||||
if len(mxTargets) == 0 {
|
||||
var err error
|
||||
mxTargets, err = lookupMX(lookupCtx, resolver, domain)
|
||||
if err != nil {
|
||||
data.MX.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 7505 null MX sentinel.
|
||||
if len(mxTargets) == 1 && (mxTargets[0].Target == "" || mxTargets[0].Target == ".") && mxTargets[0].Preference == 0 {
|
||||
data.MX.NullMX = true
|
||||
data.Issues = deriveIssues(data)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if len(mxTargets) == 0 && data.MX.Error == "" {
|
||||
// Implicit MX (RFC 5321 § 5.1): fall back to the bare domain.
|
||||
data.MX.ImplicitMX = true
|
||||
mxTargets = []mxTargetRaw{{Preference: 0, Target: domain}}
|
||||
}
|
||||
|
||||
for _, t := range mxTargets {
|
||||
rec := MXRecord{
|
||||
Preference: t.Preference,
|
||||
Target: strings.TrimSuffix(t.Target, "."),
|
||||
}
|
||||
if rec.Target == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(rec.Target); ip != nil {
|
||||
rec.IsIPLiteral = true
|
||||
}
|
||||
// Detect CNAME (RFC 5321 § 5.1 forbids MX → CNAME).
|
||||
if !rec.IsIPLiteral {
|
||||
if cname, err := resolver.LookupCNAME(lookupCtx, rec.Target); err == nil {
|
||||
canon := strings.TrimSuffix(cname, ".")
|
||||
if canon != "" && !strings.EqualFold(canon, rec.Target) {
|
||||
rec.IsCNAME = true
|
||||
rec.CNAMEChain = []string{rec.Target, canon}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rec.IsIPLiteral {
|
||||
if ip := net.ParseIP(rec.Target); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
rec.IPv4 = append(rec.IPv4, v4.String())
|
||||
} else {
|
||||
rec.IPv6 = append(rec.IPv6, ip.String())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ips, err := resolver.LookupIPAddr(lookupCtx, rec.Target)
|
||||
if err != nil {
|
||||
rec.ResolveError = err.Error()
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.IP.To4(); v4 != nil {
|
||||
rec.IPv4 = append(rec.IPv4, v4.String())
|
||||
} else {
|
||||
rec.IPv6 = append(rec.IPv6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
data.MX.Records = append(data.MX.Records, rec)
|
||||
}
|
||||
|
||||
// Probe every (target, ip) pair.
|
||||
for _, rec := range data.MX.Records {
|
||||
for _, ip := range rec.IPv4 {
|
||||
ep := probeEndpoint(ctx, probeInputs{
|
||||
target: rec.Target,
|
||||
ip: ip,
|
||||
isV6: false,
|
||||
domain: domain,
|
||||
heloName: helo,
|
||||
timeout: perEndpoint,
|
||||
testNull: testNull,
|
||||
testPostmaster: testPostmaster,
|
||||
testOpenRelay: testOpenRelay,
|
||||
openRelayRcpt: probeRcpt,
|
||||
})
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
}
|
||||
for _, ip := range rec.IPv6 {
|
||||
ep := probeEndpoint(ctx, probeInputs{
|
||||
target: rec.Target,
|
||||
ip: ip,
|
||||
isV6: true,
|
||||
domain: domain,
|
||||
heloName: helo,
|
||||
timeout: perEndpoint,
|
||||
testNull: testNull,
|
||||
testPostmaster: testPostmaster,
|
||||
testOpenRelay: testOpenRelay,
|
||||
openRelayRcpt: probeRcpt,
|
||||
})
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
}
|
||||
}
|
||||
|
||||
computeCoverage(data)
|
||||
data.Issues = deriveIssues(data)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type mxTargetRaw struct {
|
||||
Preference uint16
|
||||
Target string
|
||||
}
|
||||
|
||||
// parseServiceBody extracts the MX list from a happyDomain svcs.MXs
|
||||
// payload. Returns nil when the payload doesn't look like one; we fall
|
||||
// back to a live DNS lookup in that case.
|
||||
func parseServiceBody(raw json.RawMessage) []mxTargetRaw {
|
||||
// happyDomain wraps the body in ServiceMessage{Type, Service:<body>}.
|
||||
// We accept either the full ServiceMessage or the body directly.
|
||||
var envelope struct {
|
||||
Type string `json:"_svctype"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
var body json.RawMessage
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil && len(envelope.Service) > 0 {
|
||||
body = envelope.Service
|
||||
} else {
|
||||
body = raw
|
||||
}
|
||||
var parsed mxServiceBody
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]mxTargetRaw, 0, len(parsed.MXs))
|
||||
for _, m := range parsed.MXs {
|
||||
out = append(out, mxTargetRaw{Preference: m.Preference, Target: m.Mx})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupMX runs a DNS MX query and returns the records, or nil when
|
||||
// NXDOMAIN / no records (so the caller can trigger the implicit-MX path).
|
||||
func lookupMX(ctx context.Context, r *net.Resolver, domain string) ([]mxTargetRaw, error) {
|
||||
records, err := r.LookupMX(ctx, dns.Fqdn(domain))
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
// net.LookupMX returns an error on the RFC 7505 null-MX sentinel
|
||||
// because "." fails host validation. Surface it as a synthetic
|
||||
// record so the caller can detect the null-MX case.
|
||||
if strings.Contains(err.Error(), "cannot unmarshal DNS message") {
|
||||
return []mxTargetRaw{{Preference: 0, Target: "."}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]mxTargetRaw, 0, len(records))
|
||||
for _, m := range records {
|
||||
out = append(out, mxTargetRaw{Preference: m.Pref, Target: strings.TrimSuffix(m.Host, ".")})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type probeInputs struct {
|
||||
target, ip, domain, heloName string
|
||||
isV6 bool
|
||||
timeout time.Duration
|
||||
testNull, testPostmaster bool
|
||||
testOpenRelay bool
|
||||
openRelayRcpt string
|
||||
}
|
||||
|
||||
func probeEndpoint(ctx context.Context, in probeInputs) EndpointProbe {
|
||||
start := time.Now()
|
||||
ep := EndpointProbe{
|
||||
Target: in.target,
|
||||
Port: smtpPort,
|
||||
IP: in.ip,
|
||||
IsIPv6: in.isV6,
|
||||
Address: net.JoinHostPort(in.ip, strconv.Itoa(smtpPort)),
|
||||
}
|
||||
defer func() { ep.ElapsedMS = time.Since(start).Milliseconds() }()
|
||||
|
||||
// Reverse DNS: orthogonal to the SMTP connection, so we run it even
|
||||
// if the connection later fails.
|
||||
ptrCtx, ptrCancel := context.WithTimeout(ctx, in.timeout)
|
||||
names, err := net.DefaultResolver.LookupAddr(ptrCtx, in.ip)
|
||||
ptrCancel()
|
||||
switch {
|
||||
case err != nil:
|
||||
ep.PTRError = err.Error()
|
||||
case len(names) == 0:
|
||||
ep.PTRError = "no PTR records"
|
||||
default:
|
||||
ep.PTR = strings.TrimSuffix(names[0], ".")
|
||||
// FCrDNS: PTR's forward lookup must include our IP.
|
||||
fwdCtx, fwdCancel := context.WithTimeout(ctx, in.timeout)
|
||||
ips, ferr := net.DefaultResolver.LookupIPAddr(fwdCtx, ep.PTR)
|
||||
fwdCancel()
|
||||
if ferr == nil {
|
||||
for _, a := range ips {
|
||||
if a.IP.String() == in.ip || a.IP.Equal(net.ParseIP(in.ip)) {
|
||||
ep.FCrDNSPass = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, in.timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(dialCtx, "tcp", ep.Address)
|
||||
if err != nil {
|
||||
ep.Error = "tcp: " + err.Error()
|
||||
return ep
|
||||
}
|
||||
ep.TCPConnected = true
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(in.timeout))
|
||||
|
||||
sc := newSMTPConn(conn, in.timeout)
|
||||
|
||||
// Read the banner (220).
|
||||
code, text, _, err := sc.readResponse()
|
||||
if err != nil {
|
||||
ep.Error = "banner: " + err.Error()
|
||||
return ep
|
||||
}
|
||||
ep.BannerReceived = true
|
||||
ep.BannerCode = code
|
||||
ep.BannerLine = strings.TrimSpace(strings.ReplaceAll(text, "\n", " | "))
|
||||
ep.BannerHostname = parseBanner(text)
|
||||
if code != 220 {
|
||||
ep.Error = fmt.Sprintf("banner: unexpected code %d", code)
|
||||
return ep
|
||||
}
|
||||
|
||||
// EHLO (fall back to HELO on 5xx).
|
||||
_, text, lines, err := sc.cmd("EHLO " + in.heloName)
|
||||
if err != nil {
|
||||
ep.Error = "ehlo: " + err.Error()
|
||||
return ep
|
||||
}
|
||||
if lines[0][0] == '5' {
|
||||
// Try HELO.
|
||||
_, _, heloLines, herr := sc.cmd("HELO " + in.heloName)
|
||||
if herr != nil || len(heloLines) == 0 || heloLines[0][0] != '2' {
|
||||
ep.Error = "ehlo/helo both rejected"
|
||||
return ep
|
||||
}
|
||||
ep.EHLOReceived = true
|
||||
ep.EHLOFallbackHELO = true
|
||||
ep.EHLOHostname = strings.TrimSpace(strings.SplitN(text, " ", 2)[0])
|
||||
sc.close()
|
||||
return ep
|
||||
}
|
||||
ep.EHLOReceived = true
|
||||
greeting, exts := parseEHLO(lines)
|
||||
ep.EHLOHostname = greeting
|
||||
ep.Extensions = exts
|
||||
idx := buildExtensions(exts)
|
||||
|
||||
ep.STARTTLSOffered = idx.has("STARTTLS")
|
||||
ep.HasPipelining = idx.has("PIPELINING")
|
||||
ep.Has8BITMIME = idx.has("8BITMIME")
|
||||
ep.HasSMTPUTF8 = idx.has("SMTPUTF8")
|
||||
ep.HasCHUNKING = idx.has("CHUNKING")
|
||||
ep.HasDSN = idx.has("DSN")
|
||||
ep.HasENHANCEDCODE = idx.has("ENHANCEDSTATUSCODES")
|
||||
ep.SizeLimit = idx.parseSize()
|
||||
ep.AUTHPreTLS = idx.parseAuth()
|
||||
|
||||
// STARTTLS.
|
||||
if ep.STARTTLSOffered {
|
||||
code, _, _, terr := sc.cmd("STARTTLS")
|
||||
if terr == nil && code == 220 {
|
||||
tlsConn := tls.Client(conn, tlsProbeConfig(in.target))
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(in.timeout))
|
||||
if herr := tlsConn.Handshake(); herr != nil {
|
||||
ep.Error = "tls-handshake: " + herr.Error()
|
||||
return ep
|
||||
}
|
||||
ep.STARTTLSUpgraded = true
|
||||
state := tlsConn.ConnectionState()
|
||||
ep.TLSVersion = tls.VersionName(state.Version)
|
||||
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
sc.swap(tlsConn)
|
||||
// Re-EHLO over TLS (mandatory per RFC 3207).
|
||||
_, _, lines2, eerr := sc.cmd("EHLO " + in.heloName)
|
||||
if eerr == nil && len(lines2) > 0 && lines2[0][0] == '2' {
|
||||
_, exts2 := parseEHLO(lines2)
|
||||
ep.PostTLSExtensions = exts2
|
||||
idx2 := buildExtensions(exts2)
|
||||
ep.AUTHPostTLS = idx2.parseAuth()
|
||||
// Union the feature flags: some servers only advertise
|
||||
// 8BITMIME, PIPELINING, etc. after STARTTLS.
|
||||
if !ep.HasPipelining {
|
||||
ep.HasPipelining = idx2.has("PIPELINING")
|
||||
}
|
||||
if !ep.Has8BITMIME {
|
||||
ep.Has8BITMIME = idx2.has("8BITMIME")
|
||||
}
|
||||
if !ep.HasSMTPUTF8 {
|
||||
ep.HasSMTPUTF8 = idx2.has("SMTPUTF8")
|
||||
}
|
||||
if !ep.HasCHUNKING {
|
||||
ep.HasCHUNKING = idx2.has("CHUNKING")
|
||||
}
|
||||
if !ep.HasDSN {
|
||||
ep.HasDSN = idx2.has("DSN")
|
||||
}
|
||||
if !ep.HasENHANCEDCODE {
|
||||
ep.HasENHANCEDCODE = idx2.has("ENHANCEDSTATUSCODES")
|
||||
}
|
||||
if ep.SizeLimit == 0 {
|
||||
ep.SizeLimit = idx2.parseSize()
|
||||
}
|
||||
}
|
||||
} else if terr != nil {
|
||||
ep.Error = "starttls: " + terr.Error()
|
||||
return ep
|
||||
} else {
|
||||
ep.Error = fmt.Sprintf("starttls: unexpected code %d", code)
|
||||
// Don't bail; still run transactional probes over plaintext
|
||||
// so the operator sees what the server does without TLS.
|
||||
}
|
||||
}
|
||||
|
||||
// RCPT-level probes. Each runs in its own MAIL/RSET pair so an earlier
|
||||
// reject does not mask later ones.
|
||||
runRCPT := func(from, to string) (int, string) {
|
||||
code, text, _, err := sc.cmd("MAIL FROM:<" + from + ">")
|
||||
if err != nil {
|
||||
return -1, err.Error()
|
||||
}
|
||||
if code != 250 {
|
||||
defer sc.cmd("RSET")
|
||||
return code, strings.TrimSpace(text)
|
||||
}
|
||||
code, text, _, err = sc.cmd("RCPT TO:<" + to + ">")
|
||||
sc.cmd("RSET")
|
||||
if err != nil {
|
||||
return -1, err.Error()
|
||||
}
|
||||
return code, strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
if in.testNull {
|
||||
c, t := runRCPT("", "postmaster@"+in.domain)
|
||||
ok := c >= 200 && c < 300
|
||||
ep.NullSenderAccepted = &ok
|
||||
ep.NullSenderResponse = fmt.Sprintf("%d %s", c, t)
|
||||
}
|
||||
if in.testPostmaster {
|
||||
from := "checker@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org")
|
||||
c, t := runRCPT(from, "postmaster@"+in.domain)
|
||||
ok := c >= 200 && c < 300
|
||||
ep.PostmasterAccepted = &ok
|
||||
ep.PostmasterResponse = fmt.Sprintf("%d %s", c, t)
|
||||
}
|
||||
if in.testOpenRelay && in.openRelayRcpt != "" {
|
||||
from := "checker@" + firstNonEmpty(in.heloName, "mx-checker.happydomain.org")
|
||||
c, t := runRCPT(from, in.openRelayRcpt)
|
||||
ok := c >= 200 && c < 300
|
||||
ep.OpenRelay = &ok
|
||||
ep.OpenRelayResponse = fmt.Sprintf("%d %s", c, t)
|
||||
ep.OpenRelayRecipient = in.openRelayRcpt
|
||||
}
|
||||
|
||||
sc.close()
|
||||
return ep
|
||||
}
|
||||
|
||||
func firstNonEmpty(a ...string) string {
|
||||
for _, s := range a {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitMail(addr string) (domain, local string, ok bool) {
|
||||
at := strings.LastIndex(addr, "@")
|
||||
if at <= 0 || at == len(addr)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return addr[at+1:], addr[:at], true
|
||||
}
|
||||
|
||||
func computeCoverage(data *SMTPData) {
|
||||
if len(data.Endpoints) == 0 {
|
||||
return
|
||||
}
|
||||
allSTARTTLS := true
|
||||
allAcceptMail := true
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
data.Coverage.AnyReachable = true
|
||||
if ep.IsIPv6 {
|
||||
data.Coverage.HasIPv6 = true
|
||||
} else {
|
||||
data.Coverage.HasIPv4 = true
|
||||
}
|
||||
}
|
||||
if ep.BannerReceived {
|
||||
data.Coverage.AnyBanner = true
|
||||
}
|
||||
if ep.EHLOReceived {
|
||||
data.Coverage.AnyEHLO = true
|
||||
}
|
||||
if ep.STARTTLSUpgraded {
|
||||
data.Coverage.AnySTARTTLS = true
|
||||
} else {
|
||||
allSTARTTLS = false
|
||||
}
|
||||
// An endpoint "accepts mail" when the null-sender probe, if run,
|
||||
// was accepted and the postmaster probe, if run, was accepted.
|
||||
acc := true
|
||||
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
|
||||
acc = false
|
||||
}
|
||||
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
|
||||
acc = false
|
||||
}
|
||||
if !ep.EHLOReceived {
|
||||
acc = false
|
||||
}
|
||||
if !acc {
|
||||
allAcceptMail = false
|
||||
}
|
||||
}
|
||||
data.Coverage.AllSTARTTLS = allSTARTTLS
|
||||
data.Coverage.AllAcceptMail = allAcceptMail
|
||||
}
|
||||
85
checker/definition.go
Normal file
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 Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "smtp",
|
||||
Name: "SMTP Server (MX)",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"svcs.MXs"},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeySMTP},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 12,
|
||||
},
|
||||
{
|
||||
Id: "helo_name",
|
||||
Type: "string",
|
||||
Label: "EHLO hostname",
|
||||
Placeholder: "mx-checker.happydomain.org",
|
||||
Default: "mx-checker.happydomain.org",
|
||||
Description: "The hostname announced in EHLO/HELO. Use a name that resolves and has a valid PTR; some large providers tarpit or reject probes from unresolvable EHLO names.",
|
||||
},
|
||||
{
|
||||
Id: "test_null_sender",
|
||||
Type: "bool",
|
||||
Label: "Probe null sender (MAIL FROM:<>)",
|
||||
Default: true,
|
||||
Description: "RFC 5321 mandates that bounces with an empty envelope sender are accepted; servers that reject <> cannot receive DSNs.",
|
||||
},
|
||||
{
|
||||
Id: "test_postmaster",
|
||||
Type: "bool",
|
||||
Label: "Probe RCPT TO:<postmaster@domain>",
|
||||
Default: true,
|
||||
Description: "RFC 5321 § 4.5.1 requires every host to accept mail for <postmaster>. The probe stops at RCPT, no DATA is transmitted.",
|
||||
},
|
||||
{
|
||||
Id: "test_open_relay",
|
||||
Type: "bool",
|
||||
Label: "Probe open-relay posture",
|
||||
Default: true,
|
||||
Description: "Attempts MAIL FROM:<probe@happydomain.test> RCPT TO:<postmaster@example.com>. A 2xx on a recipient outside the tested domain indicates an open relay. The probe stops at RCPT; no DATA is transmitted.",
|
||||
},
|
||||
{
|
||||
Id: "test_probe_address",
|
||||
Type: "string",
|
||||
Label: "Open-relay probe recipient",
|
||||
Placeholder: "postmaster@example.com",
|
||||
Default: "postmaster@example.com",
|
||||
Description: "Recipient used for the open-relay probe. Must be a mailbox outside the tested domain.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{Rule()},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
77
checker/provider.go
Normal file
77
checker/provider.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &smtpProvider{}
|
||||
}
|
||||
|
||||
type smtpProvider struct{}
|
||||
|
||||
func (p *smtpProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeySMTP
|
||||
}
|
||||
|
||||
// Definition implements sdk.CheckerDefinitionProvider.
|
||||
func (p *smtpProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
//
|
||||
// We publish one tls.endpoint.v1 entry per MX target so the TLS checker
|
||||
// picks up the connection and runs the cert/chain/SAN/expiry posture
|
||||
// against it. STARTTLS is "smtp" and RequireSTARTTLS stays false because
|
||||
// MX SMTP on port 25 is opportunistic (RFC 7672 / RFC 8461 are what turn
|
||||
// it into a hard requirement, and they live in separate checkers).
|
||||
//
|
||||
// SNI is the MX target hostname: that is the name the receiving MTA
|
||||
// controls and will typically present in its certificate. RFC 7672
|
||||
// DANE-TLSA binds the TLSA record to <_port._tcp.mx-target>, so the
|
||||
// target's A/AAAA+name are also the right reference for DANE.
|
||||
func (p *smtpProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*SMTPData)
|
||||
if !ok || d == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if d.MX.NullMX {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out []sdk.DiscoveryEntry
|
||||
seen := map[string]bool{}
|
||||
for _, rec := range d.MX.Records {
|
||||
if rec.Target == "" || rec.IsIPLiteral {
|
||||
continue
|
||||
}
|
||||
key := endpointKey(rec.Target, smtpPort)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
ep := tlsct.TLSEndpoint{
|
||||
Host: rec.Target,
|
||||
Port: smtpPort,
|
||||
SNI: rec.Target,
|
||||
STARTTLS: "smtp",
|
||||
RequireSTARTTLS: false,
|
||||
}
|
||||
entry, err := tlsct.NewEntry(ep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func endpointKey(host string, port uint16) string {
|
||||
return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
|
||||
}
|
||||
614
checker/report.go
Normal file
614
checker/report.go
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
Target string
|
||||
}
|
||||
|
||||
type reportMX struct {
|
||||
Preference uint16
|
||||
Target string
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
IsCNAME bool
|
||||
CNAMEChain []string
|
||||
IsIPLiteral bool
|
||||
ResolveErr string
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
Target string
|
||||
Address string
|
||||
IP string
|
||||
IsIPv6 bool
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
AnyFail bool
|
||||
TCPConnected bool
|
||||
BannerLine string
|
||||
BannerHostname string
|
||||
BannerCode int
|
||||
EHLOReceived bool
|
||||
EHLOFallbackHELO bool
|
||||
EHLOHostname string
|
||||
STARTTLSOffered bool
|
||||
STARTTLSUpgraded bool
|
||||
TLSVersion string
|
||||
TLSCipher string
|
||||
SizeLimit uint64
|
||||
HasPipelining bool
|
||||
Has8BITMIME bool
|
||||
HasSMTPUTF8 bool
|
||||
HasCHUNKING bool
|
||||
HasDSN bool
|
||||
HasENHANCEDCODE bool
|
||||
AUTHPreTLS []string
|
||||
AUTHPostTLS []string
|
||||
PTR string
|
||||
PTRError string
|
||||
FCrDNSPass bool
|
||||
NullSenderState string
|
||||
NullSenderClass string
|
||||
NullSenderResponse string
|
||||
PostmasterState string
|
||||
PostmasterClass string
|
||||
PostmasterResponse string
|
||||
OpenRelayState string
|
||||
OpenRelayClass string
|
||||
OpenRelayResponse string
|
||||
OpenRelayRecipient string
|
||||
ElapsedMS int64
|
||||
Error string
|
||||
|
||||
// TLS posture (from a related tls_probes observation, when available).
|
||||
TLSPosture *reportTLSPosture
|
||||
}
|
||||
|
||||
type reportTLSPosture struct {
|
||||
CheckedAt time.Time
|
||||
ChainValid *bool
|
||||
HostnameMatch *bool
|
||||
NotAfter time.Time
|
||||
Issues []reportFix
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Domain string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
Fixes []reportFix
|
||||
MX []reportMX
|
||||
NullMX bool
|
||||
ImplicitMX bool
|
||||
MXError string
|
||||
Endpoints []reportEndpoint
|
||||
HasIPv4 bool
|
||||
HasIPv6 bool
|
||||
AnySTARTTLS bool
|
||||
AllSTARTTLS bool
|
||||
HasTLSPosture bool
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{
|
||||
"deref": func(b *bool) bool { return b != nil && *b },
|
||||
"humanBytes": func(n uint64) string {
|
||||
if n == 0 {
|
||||
return "no limit"
|
||||
}
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||
f := float64(n)
|
||||
u := 0
|
||||
for f >= 1024 && u < len(units)-1 {
|
||||
f /= 1024
|
||||
u++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", f, units[u])
|
||||
},
|
||||
}).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SMTP Report: {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
.info { background: #dbeafe; color: #1e3a8a; }
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.danger { background: #fee2e2; color: #991b1b; }
|
||||
.chip.good { background: #d1fae5; color: #065f46; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.banner-text { font-family: ui-monospace, monospace; font-size: .78rem;
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; padding: .3rem .5rem;
|
||||
border-radius: 4px; color: #374151; word-break: break-all; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.check-info { color: #6b7280; }
|
||||
.relay-alert {
|
||||
background: #fef2f2; border: 2px solid #dc2626;
|
||||
border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem;
|
||||
}
|
||||
.relay-alert strong { color: #991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>SMTP: <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .NullMX}}<span class="badge info">null MX (refuses mail)</span>{{else}}
|
||||
{{if .AllSTARTTLS}}<span class="badge ok">all STARTTLS</span>
|
||||
{{else if .AnySTARTTLS}}<span class="badge warn">partial STARTTLS</span>
|
||||
{{else}}<span class="badge fail">no STARTTLS</span>{{end}}
|
||||
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .Fixes}}
|
||||
<div class="fix {{.Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.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))
|
||||
return renderReport(view)
|
||||
}
|
||||
|
||||
func renderReport(view reportData) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render smtp report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *SMTPData, related []sdk.RelatedObservation) reportData {
|
||||
tlsIssues := tlsIssuesFromRelated(related)
|
||||
tlsByAddr := indexTLSByAddress(related)
|
||||
|
||||
allIssues := append([]Issue(nil), d.Issues...)
|
||||
allIssues = append(allIssues, tlsIssues...)
|
||||
|
||||
view := reportData{
|
||||
Domain: d.Domain,
|
||||
RunAt: d.RunAt,
|
||||
NullMX: d.MX.NullMX,
|
||||
ImplicitMX: d.MX.ImplicitMX,
|
||||
MXError: d.MX.Error,
|
||||
HasIPv4: d.Coverage.HasIPv4,
|
||||
HasIPv6: d.Coverage.HasIPv6,
|
||||
AnySTARTTLS: d.Coverage.AnySTARTTLS,
|
||||
AllSTARTTLS: d.Coverage.AllSTARTTLS,
|
||||
HasIssues: len(allIssues) > 0,
|
||||
HasTLSPosture: len(tlsByAddr) > 0,
|
||||
}
|
||||
|
||||
worst := SeverityInfo
|
||||
for _, is := range allIssues {
|
||||
if is.Severity == SeverityCrit {
|
||||
worst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if is.Severity == SeverityWarn {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
}
|
||||
if d.MX.NullMX {
|
||||
view.StatusLabel = "NULL MX"
|
||||
view.StatusClass = "info"
|
||||
} else if len(allIssues) == 0 {
|
||||
view.StatusLabel = "OK"
|
||||
view.StatusClass = "ok"
|
||||
} else {
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
view.StatusLabel = "FAIL"
|
||||
view.StatusClass = "fail"
|
||||
case SeverityWarn:
|
||||
view.StatusLabel = "WARN"
|
||||
view.StatusClass = "warn"
|
||||
default:
|
||||
view.StatusLabel = "INFO"
|
||||
view.StatusClass = "info"
|
||||
}
|
||||
}
|
||||
|
||||
sevRank := func(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
|
||||
for _, is := range allIssues {
|
||||
view.Fixes = append(view.Fixes, reportFix{
|
||||
Severity: is.Severity,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
Endpoint: is.Endpoint,
|
||||
Target: is.Target,
|
||||
})
|
||||
}
|
||||
|
||||
for _, rec := range d.MX.Records {
|
||||
view.MX = append(view.MX, reportMX{
|
||||
Preference: rec.Preference,
|
||||
Target: rec.Target,
|
||||
IPv4: rec.IPv4,
|
||||
IPv6: rec.IPv6,
|
||||
IsCNAME: rec.IsCNAME,
|
||||
CNAMEChain: rec.CNAMEChain,
|
||||
IsIPLiteral: rec.IsIPLiteral,
|
||||
ResolveErr: rec.ResolveError,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
Target: ep.Target,
|
||||
Address: ep.Address,
|
||||
IP: ep.IP,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
BannerLine: ep.BannerLine,
|
||||
BannerHostname: ep.BannerHostname,
|
||||
BannerCode: ep.BannerCode,
|
||||
EHLOReceived: ep.EHLOReceived,
|
||||
EHLOFallbackHELO: ep.EHLOFallbackHELO,
|
||||
EHLOHostname: ep.EHLOHostname,
|
||||
STARTTLSOffered: ep.STARTTLSOffered,
|
||||
STARTTLSUpgraded: ep.STARTTLSUpgraded,
|
||||
TLSVersion: ep.TLSVersion,
|
||||
TLSCipher: ep.TLSCipher,
|
||||
SizeLimit: ep.SizeLimit,
|
||||
HasPipelining: ep.HasPipelining,
|
||||
Has8BITMIME: ep.Has8BITMIME,
|
||||
HasSMTPUTF8: ep.HasSMTPUTF8,
|
||||
HasCHUNKING: ep.HasCHUNKING,
|
||||
HasDSN: ep.HasDSN,
|
||||
HasENHANCEDCODE: ep.HasENHANCEDCODE,
|
||||
AUTHPreTLS: ep.AUTHPreTLS,
|
||||
AUTHPostTLS: ep.AUTHPostTLS,
|
||||
PTR: ep.PTR,
|
||||
PTRError: ep.PTRError,
|
||||
FCrDNSPass: ep.FCrDNSPass,
|
||||
NullSenderResponse: ep.NullSenderResponse,
|
||||
PostmasterResponse: ep.PostmasterResponse,
|
||||
OpenRelayResponse: ep.OpenRelayResponse,
|
||||
OpenRelayRecipient: ep.OpenRelayRecipient,
|
||||
ElapsedMS: ep.ElapsedMS,
|
||||
Error: ep.Error,
|
||||
}
|
||||
if ep.NullSenderAccepted != nil {
|
||||
if *ep.NullSenderAccepted {
|
||||
re.NullSenderState = "accepted"
|
||||
re.NullSenderClass = "ok"
|
||||
} else {
|
||||
re.NullSenderState = "REJECTED"
|
||||
re.NullSenderClass = "fail"
|
||||
}
|
||||
}
|
||||
if ep.PostmasterAccepted != nil {
|
||||
if *ep.PostmasterAccepted {
|
||||
re.PostmasterState = "accepted"
|
||||
re.PostmasterClass = "ok"
|
||||
} else {
|
||||
re.PostmasterState = "REJECTED"
|
||||
re.PostmasterClass = "fail"
|
||||
}
|
||||
}
|
||||
if ep.OpenRelay != nil {
|
||||
if *ep.OpenRelay {
|
||||
re.OpenRelayState = "OPEN RELAY"
|
||||
re.OpenRelayClass = "fail"
|
||||
} else {
|
||||
re.OpenRelayState = "properly refused"
|
||||
re.OpenRelayClass = "ok"
|
||||
}
|
||||
}
|
||||
if meta, hit := tlsByAddr[ep.Address]; hit {
|
||||
re.TLSPosture = meta
|
||||
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
|
||||
re.TLSPosture = meta
|
||||
}
|
||||
ok := ep.TCPConnected && ep.EHLOReceived
|
||||
if ep.STARTTLSOffered {
|
||||
ok = ok && ep.STARTTLSUpgraded
|
||||
}
|
||||
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
|
||||
ok = false
|
||||
}
|
||||
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
|
||||
ok = false
|
||||
}
|
||||
if ep.OpenRelay != nil && *ep.OpenRelay {
|
||||
ok = false
|
||||
}
|
||||
re.AnyFail = !ok
|
||||
switch {
|
||||
case !ep.TCPConnected:
|
||||
re.StatusLabel = "unreachable"
|
||||
re.StatusClass = "fail"
|
||||
case ep.OpenRelay != nil && *ep.OpenRelay:
|
||||
re.StatusLabel = "OPEN RELAY"
|
||||
re.StatusClass = "fail"
|
||||
case !ok:
|
||||
re.StatusLabel = "partial"
|
||||
re.StatusClass = "warn"
|
||||
default:
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
}
|
||||
view.Endpoints = append(view.Endpoints, re)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
|
||||
out := map[string]*reportTLSPosture{}
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
posture := &reportTLSPosture{
|
||||
CheckedAt: r.CollectedAt,
|
||||
ChainValid: v.ChainValid,
|
||||
HostnameMatch: v.HostnameMatch,
|
||||
NotAfter: v.NotAfter,
|
||||
}
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
|
||||
continue
|
||||
}
|
||||
posture.Issues = append(posture.Issues, reportFix{
|
||||
Severity: sev,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
})
|
||||
}
|
||||
out[addr] = posture
|
||||
}
|
||||
return out
|
||||
}
|
||||
125
checker/rule.go
Normal file
125
checker/rule.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Rule() sdk.CheckRule {
|
||||
return &smtpRule{}
|
||||
}
|
||||
|
||||
type smtpRule struct{}
|
||||
|
||||
func (r *smtpRule) Name() string {
|
||||
return "smtp_server"
|
||||
}
|
||||
|
||||
func (r *smtpRule) Description() string {
|
||||
return "Checks MX discovery, SMTP connectivity, STARTTLS, PTR/FCrDNS and mail-acceptance posture for an email-receiving domain"
|
||||
}
|
||||
|
||||
func (r *smtpRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||
var data SMTPData
|
||||
if err := obs.Get(ctx, ObservationKeySMTP, &data); err != nil {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load SMTP observation: %v", err),
|
||||
Code: "smtp.observation_error",
|
||||
}
|
||||
}
|
||||
|
||||
issues := append([]Issue(nil), data.Issues...)
|
||||
|
||||
// Fold related TLS observations so cert/chain problems show up on the
|
||||
// SMTP service page without requiring the user to open a separate
|
||||
// report.
|
||||
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||
issues = append(issues, tlsIssuesFromRelated(related)...)
|
||||
|
||||
worst := sdk.StatusOK
|
||||
critMsgs, warnMsgs := []string{}, []string{}
|
||||
var firstCritCode, firstWarnCode string
|
||||
|
||||
for _, is := range issues {
|
||||
switch is.Severity {
|
||||
case SeverityCrit:
|
||||
if worst < sdk.StatusCrit {
|
||||
worst = sdk.StatusCrit
|
||||
}
|
||||
if firstCritCode == "" {
|
||||
firstCritCode = is.Code
|
||||
}
|
||||
critMsgs = append(critMsgs, is.Message)
|
||||
case SeverityWarn:
|
||||
if worst < sdk.StatusWarn {
|
||||
worst = sdk.StatusWarn
|
||||
}
|
||||
if firstWarnCode == "" {
|
||||
firstWarnCode = is.Code
|
||||
}
|
||||
warnMsgs = append(warnMsgs, is.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Null-MX is a *declared* refusal of mail; it's an INFO, not a fail.
|
||||
if data.MX.NullMX {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: "Domain refuses all email via null MX (RFC 7505).",
|
||||
Code: CodeNullMX,
|
||||
Meta: map[string]any{
|
||||
"null_mx": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"has_ipv4": data.Coverage.HasIPv4,
|
||||
"has_ipv6": data.Coverage.HasIPv6,
|
||||
"any_starttls": data.Coverage.AnySTARTTLS,
|
||||
"all_starttls": data.Coverage.AllSTARTTLS,
|
||||
"all_accept_mail": data.Coverage.AllAcceptMail,
|
||||
"mx_count": len(data.MX.Records),
|
||||
"endpoint_count": len(data.Endpoints),
|
||||
"native_issues": len(data.Issues),
|
||||
"related_tls_count": len(related),
|
||||
}
|
||||
|
||||
switch worst {
|
||||
case sdk.StatusOK:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("SMTP operational (%d MX, %d endpoints, TLS=%v)", len(data.MX.Records), len(data.Endpoints), data.Coverage.AllSTARTTLS),
|
||||
Code: "smtp.ok",
|
||||
Meta: meta,
|
||||
}
|
||||
case sdk.StatusWarn:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: "SMTP works with warnings: " + joinTop(warnMsgs, 2),
|
||||
Code: firstWarnCode,
|
||||
Meta: meta,
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "SMTP broken: " + joinTop(critMsgs, 2),
|
||||
Code: firstCritCode,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinTop(msgs []string, n int) string {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(msgs) <= n {
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n)
|
||||
}
|
||||
227
checker/smtp.go
Normal file
227
checker/smtp.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// smtpConn is a minimal ESMTP client focused on *observation*, not on
|
||||
// transmitting mail: we never reach DATA, and every transaction is torn
|
||||
// down with RSET + QUIT. Writing our own client (instead of using
|
||||
// net/smtp) gives us access to the raw banner text, per-line extension
|
||||
// responses, and the timing of each step.
|
||||
type smtpConn struct {
|
||||
raw net.Conn
|
||||
br *bufio.Reader
|
||||
wr io.Writer
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newSMTPConn(c net.Conn, timeout time.Duration) *smtpConn {
|
||||
return &smtpConn{
|
||||
raw: c,
|
||||
br: bufio.NewReader(c),
|
||||
wr: c,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// touch extends the per-turn deadline on the underlying connection.
|
||||
func (s *smtpConn) touch() {
|
||||
if s.timeout > 0 {
|
||||
_ = s.raw.SetDeadline(time.Now().Add(s.timeout))
|
||||
}
|
||||
}
|
||||
|
||||
// swap replaces the underlying connection (used after STARTTLS).
|
||||
func (s *smtpConn) swap(c net.Conn) {
|
||||
s.raw = c
|
||||
s.br = bufio.NewReader(c)
|
||||
s.wr = c
|
||||
s.touch()
|
||||
}
|
||||
|
||||
// writeLine writes a single CRLF-terminated line.
|
||||
func (s *smtpConn) writeLine(line string) error {
|
||||
s.touch()
|
||||
_, err := io.WriteString(s.wr, line+"\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// readResponse reads a multiline SMTP response ("xxx-...\r\nxxx ...\r\n")
|
||||
// and returns (code, full-joined-text, raw-lines, error).
|
||||
//
|
||||
// Any transport error is surfaced immediately; a malformed line (missing
|
||||
// 3-digit code, missing separator) yields an error with the offending
|
||||
// text so callers can surface it verbatim.
|
||||
func (s *smtpConn) readResponse() (code int, text string, lines []string, err error) {
|
||||
for {
|
||||
s.touch()
|
||||
line, rerr := s.br.ReadString('\n')
|
||||
if rerr != nil && line == "" {
|
||||
return 0, "", lines, rerr
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if len(line) < 4 {
|
||||
return 0, line, append(lines, line), fmt.Errorf("short SMTP line %q", line)
|
||||
}
|
||||
cStr := line[:3]
|
||||
sep := line[3]
|
||||
rest := line[4:]
|
||||
c, nerr := strconv.Atoi(cStr)
|
||||
if nerr != nil {
|
||||
return 0, line, append(lines, line), fmt.Errorf("bad SMTP code in %q", line)
|
||||
}
|
||||
if code == 0 {
|
||||
code = c
|
||||
}
|
||||
lines = append(lines, line)
|
||||
if sep == ' ' {
|
||||
text += rest
|
||||
return code, text, lines, nil
|
||||
}
|
||||
if sep == '-' {
|
||||
text += rest + "\n"
|
||||
continue
|
||||
}
|
||||
return 0, line, lines, fmt.Errorf("bad separator %q in %q", sep, line)
|
||||
}
|
||||
}
|
||||
|
||||
// cmd writes a command and returns the server response.
|
||||
func (s *smtpConn) cmd(line string) (int, string, []string, error) {
|
||||
if err := s.writeLine(line); err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
return s.readResponse()
|
||||
}
|
||||
|
||||
// close attempts a graceful QUIT then closes the socket. Errors are
|
||||
// swallowed; the caller has already captured everything interesting.
|
||||
func (s *smtpConn) close() {
|
||||
_ = s.writeLine("QUIT")
|
||||
_, _, _, _ = s.readResponse()
|
||||
_ = s.raw.Close()
|
||||
}
|
||||
|
||||
// parseBanner teases the announced hostname out of the 220 greeting.
|
||||
// ESMTP convention is "220 <hostname> <greeting-text>", but a number of
|
||||
// servers deviate, so we return the first whitespace-delimited token that
|
||||
// looks like a FQDN. Empty string when nothing looks plausible.
|
||||
func parseBanner(text string) string {
|
||||
fields := strings.Fields(text)
|
||||
for _, f := range fields {
|
||||
// Skip things that are obviously not a hostname.
|
||||
if strings.Contains(f, "@") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(f, ".") {
|
||||
continue
|
||||
}
|
||||
// Strip trailing punctuation.
|
||||
f = strings.TrimRight(f, ",;:.")
|
||||
if looksLikeHostname(f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeHostname(s string) bool {
|
||||
if s == "" || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
// A hostname has at least one dot and no invalid characters.
|
||||
if strings.ContainsAny(s, " \t\r\n<>\"()[]") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r == '.' || r == '-' || r == '_' ||
|
||||
(r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseEHLO splits an EHLO response into its keyword/arg lines. The
|
||||
// first line is the greeting hostname, subsequent lines are extensions.
|
||||
func parseEHLO(lines []string) (greeting string, extensions []string) {
|
||||
for i, l := range lines {
|
||||
if len(l) < 4 {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(l[4:])
|
||||
if i == 0 {
|
||||
greeting = payload
|
||||
continue
|
||||
}
|
||||
extensions = append(extensions, payload)
|
||||
}
|
||||
return greeting, extensions
|
||||
}
|
||||
|
||||
// extensionLookup indexes parsed EHLO extensions by their (uppercased)
|
||||
// keyword, preserving the argument portion unchanged.
|
||||
type extensionLookup map[string]string
|
||||
|
||||
func buildExtensions(exts []string) extensionLookup {
|
||||
m := extensionLookup{}
|
||||
for _, e := range exts {
|
||||
kw, arg, _ := strings.Cut(e, " ")
|
||||
m[strings.ToUpper(strings.TrimSpace(kw))] = strings.TrimSpace(arg)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m extensionLookup) has(kw string) bool {
|
||||
_, ok := m[kw]
|
||||
return ok
|
||||
}
|
||||
|
||||
// parseSize extracts the integer argument of the SIZE extension (0 when
|
||||
// absent or unparseable). SIZE may be advertised without an argument;
|
||||
// we treat that as "no limit declared".
|
||||
func (m extensionLookup) parseSize() uint64 {
|
||||
v, ok := m["SIZE"]
|
||||
if !ok || v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseUint(strings.Fields(v)[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseAuth returns the SASL mechanisms advertised under the AUTH
|
||||
// extension, upper-cased for easy comparison. Empty slice when AUTH is
|
||||
// not advertised.
|
||||
func (m extensionLookup) parseAuth() []string {
|
||||
v, ok := m["AUTH"]
|
||||
if !ok || v == "" {
|
||||
return nil
|
||||
}
|
||||
out := strings.Fields(v)
|
||||
for i := range out {
|
||||
out[i] = strings.ToUpper(out[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tlsProbeConfig mirrors the XMPP checker's stance: certificate
|
||||
// verification is checker-tls' job, so we skip it here.
|
||||
func tlsProbeConfig(serverName string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec (cert validation is the TLS checker's job)
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
177
checker/smtp_test.go
Normal file
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
|
||||
}
|
||||
189
checker/types.go
Normal file
189
checker/types.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// Package checker implements the SMTP (MX) server checker for happyDomain.
|
||||
//
|
||||
// It probes a domain's inbound-mail deployment (MX discovery, TCP
|
||||
// reachability, ESMTP banner & EHLO, STARTTLS negotiation, open-relay
|
||||
// posture, RFC 5321 § 4.5.1 postmaster acceptance, PTR / FCrDNS) and
|
||||
// reports actionable findings.
|
||||
//
|
||||
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
|
||||
// out of scope: we publish each MX target as a DiscoveryEntry of type
|
||||
// tls.endpoint.v1 with STARTTLS="smtp" so checker-tls picks up the
|
||||
// connection and runs the TLS posture checks itself. The resulting
|
||||
// observations flow back into our rule and HTML report via the SDK's
|
||||
// ObservationGetter.GetRelated / ReportContext.Related path.
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// ObservationKeySMTP is the key under which this checker's observation
|
||||
// payload is stored.
|
||||
const ObservationKeySMTP sdk.ObservationKey = "smtp"
|
||||
|
||||
// SMTPData is the full observation stored per run.
|
||||
type SMTPData struct {
|
||||
Domain string `json:"domain"`
|
||||
RunAt string `json:"run_at"`
|
||||
|
||||
MX MXLookup `json:"mx"`
|
||||
Endpoints []EndpointProbe `json:"endpoints"`
|
||||
Coverage Coverage `json:"coverage"`
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
// MXLookup captures the MX discovery step.
|
||||
type MXLookup struct {
|
||||
Records []MXRecord `json:"records,omitempty"`
|
||||
// Error is a non-NXDOMAIN DNS failure (servfail, timeout, …).
|
||||
Error string `json:"error,omitempty"`
|
||||
// NullMX is true when the sole record is "." with preference 0 (RFC 7505).
|
||||
NullMX bool `json:"null_mx,omitempty"`
|
||||
// ImplicitMX is true when no MX was published and we fell back to the
|
||||
// A/AAAA of the domain itself (RFC 5321 § 5.1, implicit MX).
|
||||
ImplicitMX bool `json:"implicit_mx,omitempty"`
|
||||
}
|
||||
|
||||
// MXRecord is a single MX RR target, expanded with per-target DNS checks.
|
||||
type MXRecord struct {
|
||||
Preference uint16 `json:"preference"`
|
||||
Target string `json:"target"`
|
||||
|
||||
// Resolution.
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
IPv6 []string `json:"ipv6,omitempty"`
|
||||
|
||||
// Error from A/AAAA resolution (empty string when OK).
|
||||
ResolveError string `json:"resolve_error,omitempty"`
|
||||
|
||||
// IsCNAME flags targets pointed at via a CNAME chain, forbidden by
|
||||
// RFC 5321 § 5.1 ("the domain name that appears in the RDATA SHOULD
|
||||
// have an associated address record"): senders MAY reject or fail.
|
||||
IsCNAME bool `json:"is_cname,omitempty"`
|
||||
CNAMEChain []string `json:"cname_chain,omitempty"`
|
||||
|
||||
// IsIPLiteral flags targets that look like an IP address instead of a
|
||||
// hostname (RFC 5321 § 5.1 forbids it).
|
||||
IsIPLiteral bool `json:"is_ip_literal,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointProbe is the result of probing one (target, ip, port=25) tuple.
|
||||
type EndpointProbe struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
IP string `json:"ip"`
|
||||
IsIPv6 bool `json:"is_ipv6,omitempty"`
|
||||
|
||||
// Address is "ip:port"; used as a stable key.
|
||||
Address string `json:"address"`
|
||||
|
||||
// Timing & errors.
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Connection stages.
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
BannerReceived bool `json:"banner_received"`
|
||||
BannerLine string `json:"banner_line,omitempty"`
|
||||
BannerHostname string `json:"banner_hostname,omitempty"`
|
||||
BannerCode int `json:"banner_code,omitempty"`
|
||||
EHLOReceived bool `json:"ehlo_received"`
|
||||
EHLOHostname string `json:"ehlo_hostname,omitempty"`
|
||||
EHLOFallbackHELO bool `json:"ehlo_fallback_helo,omitempty"`
|
||||
|
||||
// Pre-TLS extensions.
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
STARTTLSOffered bool `json:"starttls_offered"`
|
||||
AUTHPreTLS []string `json:"auth_pre_tls,omitempty"`
|
||||
SizeLimit uint64 `json:"size_limit,omitempty"`
|
||||
HasPipelining bool `json:"has_pipelining,omitempty"`
|
||||
Has8BITMIME bool `json:"has_8bitmime,omitempty"`
|
||||
HasSMTPUTF8 bool `json:"has_smtputf8,omitempty"`
|
||||
HasCHUNKING bool `json:"has_chunking,omitempty"`
|
||||
HasDSN bool `json:"has_dsn,omitempty"`
|
||||
HasENHANCEDCODE bool `json:"has_enhancedstatuscodes,omitempty"`
|
||||
|
||||
// STARTTLS negotiation.
|
||||
STARTTLSUpgraded bool `json:"starttls_upgraded,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
TLSCipher string `json:"tls_cipher,omitempty"`
|
||||
|
||||
// Post-TLS extensions (typically identical or larger than pre-TLS).
|
||||
PostTLSExtensions []string `json:"post_tls_extensions,omitempty"`
|
||||
AUTHPostTLS []string `json:"auth_post_tls,omitempty"`
|
||||
|
||||
// Reverse DNS / FCrDNS.
|
||||
PTR string `json:"ptr,omitempty"`
|
||||
PTRError string `json:"ptr_error,omitempty"`
|
||||
FCrDNSPass bool `json:"fcrdns_pass,omitempty"`
|
||||
|
||||
// Mail transaction probes.
|
||||
NullSenderAccepted *bool `json:"null_sender_accepted,omitempty"`
|
||||
NullSenderResponse string `json:"null_sender_response,omitempty"`
|
||||
|
||||
PostmasterAccepted *bool `json:"postmaster_accepted,omitempty"`
|
||||
PostmasterResponse string `json:"postmaster_response,omitempty"`
|
||||
|
||||
OpenRelay *bool `json:"open_relay,omitempty"`
|
||||
OpenRelayResponse string `json:"open_relay_response,omitempty"`
|
||||
OpenRelayRecipient string `json:"open_relay_recipient,omitempty"`
|
||||
}
|
||||
|
||||
// Coverage summarises which axes are working at the domain level.
|
||||
type Coverage struct {
|
||||
HasIPv4 bool `json:"has_ipv4"`
|
||||
HasIPv6 bool `json:"has_ipv6"`
|
||||
AnyReachable bool `json:"any_reachable"`
|
||||
AnyBanner bool `json:"any_banner"`
|
||||
AnyEHLO bool `json:"any_ehlo"`
|
||||
AnySTARTTLS bool `json:"any_starttls"`
|
||||
AllSTARTTLS bool `json:"all_starttls"`
|
||||
AllAcceptMail bool `json:"all_accept_mail"`
|
||||
}
|
||||
|
||||
// Issue is a structured finding, consumed by both the rule and the HTML report.
|
||||
type Issue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"` // "info" | "warn" | "crit"
|
||||
Message string `json:"message"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// Severities (string for stable JSON, independent of sdk.Status numeric values).
|
||||
const (
|
||||
SeverityInfo = "info"
|
||||
SeverityWarn = "warn"
|
||||
SeverityCrit = "crit"
|
||||
)
|
||||
|
||||
// Issue codes.
|
||||
const (
|
||||
CodeNoMX = "smtp.no_mx"
|
||||
CodeMXLookupFailed = "smtp.mx.lookup_failed"
|
||||
CodeNullMX = "smtp.null_mx"
|
||||
CodeImplicitMX = "smtp.mx.implicit"
|
||||
CodeMXCNAME = "smtp.mx.cname"
|
||||
CodeMXIPLiteral = "smtp.mx.ip_literal"
|
||||
CodeMXResolveFailed = "smtp.mx.resolve_failed"
|
||||
CodeNoAddresses = "smtp.mx.no_addresses"
|
||||
CodeTCPUnreachable = "smtp.tcp.unreachable"
|
||||
CodeBannerMissing = "smtp.banner.missing"
|
||||
CodeBannerInvalid = "smtp.banner.invalid"
|
||||
CodeEHLOFailed = "smtp.ehlo.failed"
|
||||
CodeEHLOFallback = "smtp.ehlo.fallback_helo"
|
||||
CodeSTARTTLSMissing = "smtp.starttls.missing"
|
||||
CodeSTARTTLSFailed = "smtp.starttls.failed"
|
||||
CodeAUTHOverPlain = "smtp.auth.plaintext"
|
||||
CodePTRMissing = "smtp.ptr.missing"
|
||||
CodeFCrDNSMismatch = "smtp.fcrdns.mismatch"
|
||||
CodeNullSenderReject = "smtp.null_sender.rejected"
|
||||
CodePostmasterReject = "smtp.postmaster.rejected"
|
||||
CodeOpenRelay = "smtp.open_relay"
|
||||
CodeNoPipelining = "smtp.no_pipelining"
|
||||
CodeNo8BITMIME = "smtp.no_8bitmime"
|
||||
CodeNoIPv6 = "smtp.no_ipv6"
|
||||
CodeAllEndpointsDown = "smtp.all_endpoints_down"
|
||||
CodeAllNoSTARTTLS = "smtp.all_no_starttls"
|
||||
)
|
||||
17
go.mod
Normal file
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.1.0
|
||||
git.happydns.org/checker-tls v0.1.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
|
||||
git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
|
||||
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.1.0 h1:eFHn9B5GKfc/lKdRRgOu+gf5FiMqwna43ftIXTbBLhc=
|
||||
git.happydns.org/checker-tls v0.1.0/go.mod h1:dJR9pIejNQU6TR0R0Ik4bXX77+q1jVeOVvaRM5iYZt8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
27
main.go
Normal file
27
main.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
smtp "git.happydns.org/checker-smtp/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. Overridden at link time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
smtp.Version = Version
|
||||
|
||||
server := sdk.NewServer(smtp.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
19
plugin/plugin.go
Normal file
19
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the SMTP checker.
|
||||
//
|
||||
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||
// runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
smtp "git.happydns.org/checker-smtp/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||
// .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
smtp.Version = Version
|
||||
return smtp.Definition(), smtp.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue