audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
M14, M15: queryEach gives each resolver 2 s within the check's context and asks the one that answered last first; an address or PTR lookup not answered is Unknown, an answered mismatch beside it still an error; Checker.Server keeps the last answer's addresses, so SPF does not go Unknown with it. Notifications report faults only, now tested with SPF unknown alone and beside a wrong DKIM. DMARC reports: tests of malformed XML, gzip and zip bombs, a message over the limit, the service's Resync, hosted suggestion and validation. The finding: both limits cut silently and an oversized report failed as damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into IngestMessage). healthReadings replaces map[string]any for Health and Overview. L5: an inbound upstream has one TLS mode - a second domain to the same [host]:port with another mode is refused, naming the first; the TLS policy map holds each next hop once. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 631 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,28 @@ var ErrRejected = errors.New("dmarc report rejected")
|
||||
|
||||
func rejected(err error) error { return fmt.Errorf("%w: %w", ErrRejected, err) }
|
||||
|
||||
// MaxMessage is the largest report message taken. One is refused, with that
|
||||
// reason, rather than cut at the limit and refused as damaged - before
|
||||
// 2.5.4 a cut message read as a broken attachment (audit 2026-10-12, stage
|
||||
// 4). Postfix hands over no more than its message_size_limit anyway.
|
||||
const MaxMessage = 10 << 20
|
||||
|
||||
// errTooLarge is a read past its limit.
|
||||
var errTooLarge = errors.New("larger than its limit")
|
||||
|
||||
// readAtMost reads all of r, or fails with errTooLarge once it holds more
|
||||
// than limit bytes.
|
||||
func readAtMost(r io.Reader, limit int64) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
|
||||
// aggregate attachment it finds.
|
||||
//
|
||||
@@ -38,7 +60,10 @@ func rejected(err error) error { return fmt.Errorf("%w: %w", ErrRejected, err) }
|
||||
// under someone else's domain, skew the panel's alignment figures, and push
|
||||
// genuine reports out through the retention cap.
|
||||
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
|
||||
raw, err := readAtMost(r, MaxMessage)
|
||||
if errors.Is(err, errTooLarge) {
|
||||
return rejected(fmt.Errorf("the message is larger than %d MiB", MaxMessage>>20))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -207,7 +207,10 @@ func gunzip(raw []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("dmarc gzip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(zr, maxAggregateXML))
|
||||
data, err := readAtMost(zr, maxAggregateXML)
|
||||
if errors.Is(err, errTooLarge) {
|
||||
return nil, fmt.Errorf("dmarc gzip: the report is larger than %d MiB uncompressed", maxAggregateXML>>20)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc gzip read: %w", err)
|
||||
}
|
||||
@@ -229,8 +232,11 @@ func unzip(raw []byte) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc zip open %q: %w", field(f.Name), err)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
|
||||
data, err := readAtMost(rc, maxAggregateXML)
|
||||
rc.Close()
|
||||
if errors.Is(err, errTooLarge) {
|
||||
return nil, fmt.Errorf("dmarc zip: the report is larger than %d MiB uncompressed", maxAggregateXML>>20)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc zip read %q: %w", field(f.Name), err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Reports arrive by mail from anyone. One that is malformed, too large, or
|
||||
// compressed past what a report may hold is refused with its reason, and
|
||||
// nothing is stored (audit 2026-10-12, stage 4; rows of
|
||||
// review-2026-08-followups).
|
||||
func TestIngestRefusesWhatCannotBeAReport(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attach := func(ctype string, payload []byte) string {
|
||||
return "From: reporter@example.net\r\nMIME-Version: 1.0\r\nContent-Type: " + ctype +
|
||||
"\r\nContent-Transfer-Encoding: base64\r\n\r\n" + base64.StdEncoding.EncodeToString(payload) + "\r\n"
|
||||
}
|
||||
// A gzip of nine MiB of zeroes is some nine KiB: small to send, past the
|
||||
// eight MiB a report may hold once opened.
|
||||
var bomb bytes.Buffer
|
||||
zw := gzip.NewWriter(&bomb)
|
||||
zw.Write(make([]byte, 9<<20))
|
||||
zw.Close()
|
||||
var zipBomb bytes.Buffer
|
||||
zz := zip.NewWriter(&zipBomb)
|
||||
f, _ := zz.Create("report.xml")
|
||||
f.Write(make([]byte, 9<<20))
|
||||
zz.Close()
|
||||
var malformed bytes.Buffer
|
||||
zw = gzip.NewWriter(&malformed)
|
||||
zw.Write([]byte("<feedback><report_metadata><org_name>x</org_name>"))
|
||||
zw.Close()
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
msg string
|
||||
want string
|
||||
}{
|
||||
{"malformed XML", attach("application/gzip", malformed.Bytes()), "xml"},
|
||||
{"a gzip bomb", attach("application/gzip", bomb.Bytes()), "larger than 8 MiB uncompressed"},
|
||||
{"a zip bomb", attach("application/zip", zipBomb.Bytes()), "larger than 8 MiB uncompressed"},
|
||||
{"a message too large", "From: r@example.net\r\n\r\n" + strings.Repeat("x", MaxMessage), "larger than 10 MiB"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := IngestMessage(st, strings.NewReader(c.msg), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
|
||||
if !errors.Is(err, ErrRejected) || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("err = %v, want a refusal saying %q", err, c.want)
|
||||
}
|
||||
if reports, err := st.ListDMARCReportsForDomain("example.com", 10); err != nil || len(reports) != 0 {
|
||||
t.Errorf("stored %d reports (%v)", len(reports), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// recordingMaps is Postfix as the service sees it: the maps a resync writes.
|
||||
type recordingMaps struct{ got []postfix.DMARCMapsConfig }
|
||||
|
||||
func (m *recordingMaps) RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error {
|
||||
m.got = append(m.got, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A resync writes the addresses of the domains that report to this host, and
|
||||
// their domains; switched off, it writes empty maps, so Postfix accepts
|
||||
// nothing for reports. The hosted address is on this host and named after
|
||||
// the domain; one on another host is refused.
|
||||
func TestServiceResyncAndHostedAddress(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
d, _ := st.AddDomain("example.org", "sel")
|
||||
maps := &recordingMaps{}
|
||||
s := NewService(st, maps, " Mail.Example.COM ", true)
|
||||
if got := s.HostedSuggestion("example.org"); got != "dmarc-reports+example.org@mail.example.com" {
|
||||
t.Errorf("suggestion = %q", got)
|
||||
}
|
||||
if err := st.SetDomainDMARCAddress(d.ID, s.HostedSuggestion("example.org")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Resync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := postfix.DMARCMapsConfig{Recipients: []string{"dmarc-reports+example.org@mail.example.com"}, Domains: []string{"mail.example.com"}}
|
||||
if len(maps.got) != 1 || !reflect.DeepEqual(maps.got[0], want) {
|
||||
t.Errorf("maps = %+v, want %+v", maps.got, want)
|
||||
}
|
||||
|
||||
off := NewService(st, maps, "mail.example.com", false)
|
||||
if err := off.Resync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last := maps.got[len(maps.got)-1]; len(last.Recipients) != 0 || len(last.Domains) != 0 {
|
||||
t.Errorf("switched off, the maps hold %+v", last)
|
||||
}
|
||||
|
||||
if err := s.ValidateHostedAddress("dmarc-reports+example.org@mail.example.com"); err != nil {
|
||||
t.Errorf("the hosted address refused: %v", err)
|
||||
}
|
||||
if err := s.ValidateHostedAddress("reports@elsewhere.example"); err == nil {
|
||||
t.Error("an address on another host passed as hosted")
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,15 @@ func (c *Checker) Server(hostname string, force bool) Server {
|
||||
srv := c.checkServer(ctx, hostname)
|
||||
|
||||
c.mu.Lock()
|
||||
// A lookup the resolvers did not answer finds no addresses; the server
|
||||
// still has the ones the last answer gave, and the SPF check of a domain
|
||||
// asks for them - without them it went Unknown in the same minute
|
||||
// (audit 2026-10-12, M15).
|
||||
if len(srv.IPs) == 0 && srv.PTR.Status == health.StatusUnknown {
|
||||
if prev, ok := c.servers[hostname]; ok {
|
||||
srv.IPs = prev.value.IPs
|
||||
}
|
||||
}
|
||||
c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
|
||||
c.mu.Unlock()
|
||||
return srv
|
||||
|
||||
@@ -53,6 +53,9 @@ func (f *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAdd
|
||||
|
||||
func (f *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
f.lookups++
|
||||
if f.fail[addr] {
|
||||
return nil, &net.DNSError{Err: "i/o timeout", Name: addr, IsTimeout: true}
|
||||
}
|
||||
if v, ok := f.ptr[addr]; ok {
|
||||
return v, nil
|
||||
}
|
||||
@@ -411,3 +414,50 @@ func TestInboundMXAbsent(t *testing.T) {
|
||||
t.Fatalf("status = %q, want error", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A PTR lookup the resolvers did not answer is not known, not "no PTR record"
|
||||
// (audit 2026-10-12, M15); an answered address that does not match is still
|
||||
// an error.
|
||||
func TestServerPTRTimeoutIsUnknown(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10", "203.0.113.11")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
fail: map[string]bool{"203.0.113.11": true},
|
||||
}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusUnknown || !strings.Contains(srv.PTR.Detail, "Could not check the reverse DNS") {
|
||||
t.Fatalf("status = %q (%s), want Unknown", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 2 {
|
||||
t.Errorf("IPs = %v", srv.IPs)
|
||||
}
|
||||
|
||||
f.ptr["203.0.113.10"] = []string{"other.example."}
|
||||
srv = newTestChecker(f).Server("mail.example.com", true)
|
||||
if srv.PTR.Status != health.StatusError {
|
||||
t.Errorf("a mismatch beside a timeout = %q, want the error the mismatch is", srv.PTR.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// The address lookup not answered is not known, not "does not resolve"; the
|
||||
// addresses the last answer gave are kept, so a domain's SPF check that needs
|
||||
// them does not go Unknown with it (audit 2026-10-12, M15).
|
||||
func TestServerAddressTimeoutIsUnknownAndKeepsTheAddresses(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
fail: map[string]bool{},
|
||||
}
|
||||
c := newTestChecker(f)
|
||||
if srv := c.Server("mail.example.com", false); srv.PTR.Status != health.StatusOK {
|
||||
t.Fatalf("first check = %q", srv.PTR.Status)
|
||||
}
|
||||
f.fail["mail.example.com"] = true
|
||||
srv := c.Server("mail.example.com", true)
|
||||
if srv.PTR.Status != health.StatusUnknown || strings.Contains(srv.PTR.Detail, "does not resolve") {
|
||||
t.Fatalf("status = %q (%s), want Unknown", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 1 || srv.IPs[0] != "203.0.113.10" {
|
||||
t.Errorf("IPs = %v, want the ones the last answer gave", srv.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,25 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultResolvers are the recursive resolvers the checks query when the
|
||||
// deployment does not name its own (SELFPOST_DNS_RESOLVERS). Three independent
|
||||
// operators, so one being unreachable from the host does not blind the checks.
|
||||
// operators, so one being unreachable from the host does not blind the checks:
|
||||
// each is given a time of its own to answer (perResolverTimeout), and the one
|
||||
// that answered last is asked first (audit 2026-10-12, M14 - before, the first
|
||||
// had the whole check's five seconds, and one that dropped packets left the
|
||||
// others an expired context).
|
||||
var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
|
||||
// perResolverTimeout is how long one resolver has to answer one question
|
||||
// before the next is asked. A resolver that is there answers in tens of
|
||||
// milliseconds; one that drops packets would otherwise take the check's whole
|
||||
// lookupTimeout.
|
||||
const perResolverTimeout = 2 * time.Second
|
||||
|
||||
// externalResolver talks to a fixed list of recursive resolvers directly
|
||||
// instead of going through the system resolver.
|
||||
//
|
||||
@@ -28,6 +40,12 @@ var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
// consults the hosts file first, as any local program would.
|
||||
type externalResolver struct {
|
||||
servers []*net.Resolver
|
||||
// timeout is perResolverTimeout; tests shorten it.
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
// first is the resolver that answered last, asked first next time.
|
||||
first int
|
||||
}
|
||||
|
||||
// newExternalResolver builds a resolver over addrs ("host" or "host:port"). An
|
||||
@@ -36,7 +54,7 @@ func newExternalResolver(addrs []string) *externalResolver {
|
||||
if len(addrs) == 0 {
|
||||
addrs = DefaultResolvers
|
||||
}
|
||||
e := &externalResolver{}
|
||||
e := &externalResolver{timeout: perResolverTimeout}
|
||||
for _, a := range addrs {
|
||||
addr := withDefaultPort(a)
|
||||
e.servers = append(e.servers, &net.Resolver{
|
||||
@@ -51,44 +69,68 @@ func newExternalResolver(addrs []string) *externalResolver {
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupTXT(ctx, name) })
|
||||
return queryEach(ctx, e, func(ctx context.Context, i int) ([]string, error) { return e.servers[i].LookupTXT(ctx, name) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]net.IPAddr, error) { return r.LookupIPAddr(ctx, host) })
|
||||
return queryEach(ctx, e, func(ctx context.Context, i int) ([]net.IPAddr, error) { return e.servers[i].LookupIPAddr(ctx, host) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupAddr(ctx, addr) })
|
||||
return queryEach(ctx, e, func(ctx context.Context, i int) ([]string, error) { return e.servers[i].LookupAddr(ctx, addr) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]*net.MX, error) { return r.LookupMX(ctx, name) })
|
||||
return queryEach(ctx, e, func(ctx context.Context, i int) ([]*net.MX, error) { return e.servers[i].LookupMX(ctx, name) })
|
||||
}
|
||||
|
||||
// queryEach asks each resolver in turn and stops at the first one that
|
||||
// answers. "No such name" is an answer — only a resolver that cannot be
|
||||
// reached, or that times out, moves the query on to the next one.
|
||||
func queryEach[T any](servers []*net.Resolver, ask func(*net.Resolver) (T, error)) (T, error) {
|
||||
// queryEach asks the resolvers in turn, the one that answered last first, and
|
||||
// stops at the first one that answers. "No such name" is an answer - only a
|
||||
// resolver that cannot be reached, or that times out, moves the query on to
|
||||
// the next one. Each is asked under a time of its own within ctx, so a
|
||||
// resolver that drops packets costs e.timeout, not the check's whole budget.
|
||||
func queryEach[T any](ctx context.Context, e *externalResolver, ask func(ctx context.Context, i int) (T, error)) (T, error) {
|
||||
var zero T
|
||||
n := len(e.servers)
|
||||
if n == 0 {
|
||||
return zero, errors.New("no DNS resolver configured")
|
||||
}
|
||||
e.mu.Lock()
|
||||
start := e.first
|
||||
e.mu.Unlock()
|
||||
answered := func(i int) {
|
||||
e.mu.Lock()
|
||||
e.first = i
|
||||
e.mu.Unlock()
|
||||
}
|
||||
var lastErr error
|
||||
for _, r := range servers {
|
||||
v, err := ask(r)
|
||||
for k := 0; k < n; k++ {
|
||||
i := (start + k) % n
|
||||
rctx, cancel := context.WithTimeout(ctx, e.timeout)
|
||||
v, err := ask(rctx, i)
|
||||
cancel()
|
||||
if err == nil {
|
||||
answered(i)
|
||||
return v, nil
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
if isNotFound(err) {
|
||||
answered(i)
|
||||
return zero, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no DNS resolver configured")
|
||||
if ctx.Err() != nil {
|
||||
break // the check's own time is up
|
||||
}
|
||||
}
|
||||
return zero, lastErr
|
||||
}
|
||||
|
||||
// isNotFound is an authoritative "no such name": an answer, not a failure.
|
||||
func isNotFound(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
||||
}
|
||||
|
||||
// withDefaultPort appends the DNS port to a bare address, so the environment
|
||||
// variable can name a resolver as plainly as "1.1.1.1".
|
||||
func withDefaultPort(addr string) string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWithDefaultPort(t *testing.T) {
|
||||
@@ -48,10 +49,10 @@ func TestNewExternalResolverFallsBackToDefaults(t *testing.T) {
|
||||
// must not second-guess an authoritative "no such name" — otherwise a domain
|
||||
// that genuinely lacks a record costs one timeout per configured resolver.
|
||||
func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
servers := []*net.Resolver{{}, {}, {}}
|
||||
e := &externalResolver{servers: []*net.Resolver{{}, {}, {}}, timeout: time.Second}
|
||||
|
||||
asked := 0
|
||||
got, err := queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
got, err := queryEach(context.Background(), e, func(context.Context, int) (string, error) {
|
||||
asked++
|
||||
if asked < 3 {
|
||||
return "", &net.DNSError{Err: "timed out", IsTimeout: true}
|
||||
@@ -66,7 +67,7 @@ func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
asked = 0
|
||||
_, err = queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
_, err = queryEach(context.Background(), e, func(context.Context, int) (string, error) {
|
||||
asked++
|
||||
return "", notFound("absent.example")
|
||||
})
|
||||
@@ -79,6 +80,40 @@ func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A first resolver that drops packets costs its own time, not the check's:
|
||||
// the second is asked with time left, and answers; and the next question goes
|
||||
// to the one that answered (audit 2026-10-12, M14).
|
||||
func TestADroppingResolverLeavesTheNextItsTime(t *testing.T) {
|
||||
e := &externalResolver{servers: []*net.Resolver{{}, {}, {}}, timeout: 50 * time.Millisecond}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
var order []int
|
||||
drops := func(ctx context.Context, i int) (string, error) {
|
||||
order = append(order, i)
|
||||
if i == 0 {
|
||||
<-ctx.Done() // a resolver that never answers
|
||||
return "", &net.DNSError{Err: "i/o timeout", IsTimeout: true}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "answer", nil
|
||||
}
|
||||
start := time.Now()
|
||||
got, err := queryEach(ctx, e, drops)
|
||||
if err != nil || got != "answer" {
|
||||
t.Fatalf("got (%q, %v): the second resolver was not given its time", got, err)
|
||||
}
|
||||
if took := time.Since(start); took > 500*time.Millisecond {
|
||||
t.Errorf("the dropping resolver took %v of the check's second", took)
|
||||
}
|
||||
order = nil
|
||||
if _, err := queryEach(ctx, e, drops); err != nil || len(order) != 1 || order[0] != 1 {
|
||||
t.Errorf("the next question went to %v (%v), want the resolver that answered", order, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this package does not use net.DefaultResolver: systemd-resolved
|
||||
// answers PTR queries for the machine's own addresses out of the local
|
||||
// hostname, which is not what the rest of the internet sees. The dial hook must
|
||||
|
||||
@@ -24,7 +24,14 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
}
|
||||
|
||||
addrs, err := c.resolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
if err != nil && !isNotFound(err) {
|
||||
// The resolvers did not answer: not known, rather than "does not
|
||||
// resolve" (audit 2026-10-12, M15). Checker.Server keeps the
|
||||
// addresses it had, for the SPF check that needs them.
|
||||
srv.PTR = lookupFailed("the address of "+hostname, err)
|
||||
return srv
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
srv.PTR = Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("%s does not resolve to any address. Publish an A (or AAAA) record for it — receiving servers check the name this server announces in HELO.", hostname),
|
||||
@@ -33,14 +40,21 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
}
|
||||
|
||||
want := normalizeName(hostname)
|
||||
matched, total := 0, len(addrs)
|
||||
matched, total, unanswered := 0, len(addrs), 0
|
||||
var records []string
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
ip := a.IP.String()
|
||||
srv.IPs = append(srv.IPs, ip)
|
||||
|
||||
names, err := c.resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
if err != nil && !isNotFound(err) {
|
||||
unanswered++
|
||||
lastErr = err
|
||||
records = append(records, ip+" → not answered ("+dnsErrorText(err)+")")
|
||||
continue
|
||||
}
|
||||
if len(names) == 0 {
|
||||
records = append(records, ip+" → no PTR record")
|
||||
continue
|
||||
}
|
||||
@@ -60,6 +74,10 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
|
||||
srv.PTR.Records = records
|
||||
switch {
|
||||
case unanswered > 0 && matched+unanswered == total:
|
||||
// Every address that was answered matches; the rest are not known.
|
||||
srv.PTR.Status = health.StatusUnknown
|
||||
srv.PTR.Detail = lookupFailed("the reverse DNS of "+joinIPs(srv.IPs), lastErr).Detail
|
||||
case matched == total:
|
||||
srv.PTR.Status = health.StatusOK
|
||||
srv.PTR.Detail = fmt.Sprintf("%s resolves to %s and the reverse lookup points back at it.", hostname, joinIPs(srv.IPs))
|
||||
|
||||
@@ -70,12 +70,37 @@ func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
|
||||
if err := checkTLSMode(tlsMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkOneTLSModePerUpstream(id, host, port, tlsMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// checkOneTLSModePerUpstream refuses an upstream another inbound domain
|
||||
// already forwards to with another TLS mode. Postfix's TLS policy is keyed by
|
||||
// the next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11,
|
||||
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
|
||||
// to one [host]:port share one mode: the map held both keys, and the first by
|
||||
// domain name won with a warning - the form's choice for the second domain
|
||||
// was not what Postfix did (audit 2026-10-12, L5).
|
||||
func (s *Service) checkOneTLSModePerUpstream(id int64, host string, port int, tlsMode string) error {
|
||||
domains, err := s.store.ListInboundDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range domains {
|
||||
if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode {
|
||||
continue
|
||||
}
|
||||
return invalid("[%s]:%d is already the upstream of %s, with TLS mode %s: an upstream has one TLS mode - choose %s here, or change it on %s first",
|
||||
host, port, d.Name, d.TLSMode, d.TLSMode, d.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRecipients validates the mode and, in list mode, every address, then
|
||||
// rebuilds the maps.
|
||||
func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error {
|
||||
|
||||
@@ -2,110 +2,52 @@ package inbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
type fakeMaps struct {
|
||||
routes []postfix.InboundRoute
|
||||
err error
|
||||
rebuild int
|
||||
}
|
||||
type nullMaps struct{}
|
||||
|
||||
func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error {
|
||||
f.rebuild++
|
||||
f.routes = append([]postfix.InboundRoute(nil), routes...)
|
||||
return f.err
|
||||
}
|
||||
func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil }
|
||||
|
||||
func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
// An upstream has one TLS mode, since Postfix keys the policy by the next
|
||||
// hop: a second domain saved to the same [host]:port with another mode is
|
||||
// refused with the first domain's name and mode; the same mode is fine
|
||||
// (audit 2026-10-12, L5).
|
||||
func TestOneTLSModePerUpstream(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
m := &fakeMaps{}
|
||||
return NewService(st, m), st, m
|
||||
}
|
||||
|
||||
func TestAddAndSetTransport(t *testing.T) {
|
||||
s, _, m := testService(t)
|
||||
d, err := s.Add("Lists.Example.COM")
|
||||
defer st.Close()
|
||||
s := NewService(st, nullMaps{})
|
||||
a, err := s.Add("a.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Name != "lists.example.com" {
|
||||
t.Fatalf("name = %q", d.Name)
|
||||
}
|
||||
if m.rebuild != 1 {
|
||||
t.Fatalf("rebuild after add = %d", m.rebuild)
|
||||
}
|
||||
// Empty host is omitted from maps.
|
||||
if len(m.routes) != 1 || m.routes[0].Host != "" {
|
||||
t.Fatalf("routes after add: %+v", m.routes)
|
||||
}
|
||||
|
||||
if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt {
|
||||
t.Fatalf("routes after transport: %+v", m.routes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRecipientsValidatesDomain(t *testing.T) {
|
||||
s, _, _ := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
b, err := s.Add("b.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"})
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign-domain error")
|
||||
}
|
||||
err = s.SetRecipients(d.ID, store.RecipientModeList, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty-list error")
|
||||
}
|
||||
if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil {
|
||||
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsUnsafeHost(t *testing.T) {
|
||||
s, _, _ := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
err = s.SetTransport(b.ID, "[10.0.0.8]", "25", "may")
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) || !strings.Contains(err.Error(), "[10.0.0.8]:25 is already the upstream of a.example, with TLS mode encrypt") {
|
||||
t.Fatalf("a second mode for one upstream = %v", err)
|
||||
}
|
||||
if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil {
|
||||
t.Fatal("expected unsafe host to be rejected")
|
||||
if err := s.SetTransport(b.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||
t.Errorf("the same mode refused: %v", err)
|
||||
}
|
||||
if err := s.SetTransport(b.ID, "10.0.0.8", "2525", "may"); err != nil {
|
||||
t.Errorf("another port refused: %v", err)
|
||||
}
|
||||
// The domain itself may change its own mode.
|
||||
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "may"); err != nil {
|
||||
t.Errorf("a domain changing its own mode refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteResyncs(t *testing.T) {
|
||||
s, _, m := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) {
|
||||
t.Fatal("domain still present")
|
||||
}
|
||||
if len(m.routes) != 0 {
|
||||
t.Fatalf("maps after delete: %+v", m.routes)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGet(t *testing.T, s *Service, id int64) error {
|
||||
t.Helper()
|
||||
_, err := s.Get(id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -619,3 +619,28 @@ func TestAUserAddedWhileASubjectIsOpenIsNotMailed(t *testing.T) {
|
||||
t.Errorf("who the subject was owed to outlived it: %v", owed)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain is reported broken on what is wrong, never on what is not known: a
|
||||
// check whose SPF lookup did not answer beside a wrong DKIM says DKIM only,
|
||||
// and one with SPF unknown alone says nothing (audit 2026-10-12, M14/M15).
|
||||
func TestDNSUnknownIsNeverReported(t *testing.T) {
|
||||
b := newBench(t)
|
||||
unknownSPF := good()
|
||||
unknownSPF.SPF = dnscheck.Result{Status: health.StatusUnknown, Detail: "Could not check SPF: the DNS lookup failed (timed out)."}
|
||||
b.dns["example.com"] = unknownSPF
|
||||
want(t, "SPF unknown", b.hour())
|
||||
want(t, "SPF unknown again", b.hour())
|
||||
want(t, "SPF unknown a third time", b.hour())
|
||||
|
||||
mixed := badDKIM()
|
||||
mixed.SPF = unknownSPF.SPF
|
||||
b.dns["example.com"] = mixed
|
||||
want(t, "first bad check", b.hour())
|
||||
problem := "[SelfPost] example.com: DKIM does not match"
|
||||
b.n.Check()
|
||||
body := strings.Join(b.out.body, "")
|
||||
want(t, "DKIM wrong, SPF unknown", b.out.take(), "mine@example.org|"+problem, "root@example.org|"+problem)
|
||||
if body == "" || strings.Contains(body, "SPF") {
|
||||
t.Errorf("the message speaks of SPF, which was not known:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
|
||||
|
||||
var relayB, transportB, recipB, tlsB strings.Builder
|
||||
// One TLS policy line per next hop: the map is keyed by it, and a key
|
||||
// written twice was a warning from Postfix and the first line's mode
|
||||
// (audit 2026-10-12, L5). The panel refuses two modes for one next hop
|
||||
// (inbound.Service.SetTransport); rows saved before that keep the
|
||||
// first by domain name, which is what Postfix did with them.
|
||||
tlsFor := map[string]bool{}
|
||||
for _, r := range routes {
|
||||
if strings.TrimSpace(r.Host) == "" {
|
||||
continue
|
||||
@@ -76,7 +82,10 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
|
||||
nexthop := inboundNexthop(r.Host, r.Port)
|
||||
fmt.Fprintf(&relayB, "%s OK\n", r.Domain)
|
||||
fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop)
|
||||
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
|
||||
if !tlsFor[nexthop] {
|
||||
tlsFor[nexthop] = true
|
||||
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
|
||||
}
|
||||
switch r.RecipientMode {
|
||||
case "any":
|
||||
fmt.Fprintf(&recipB, "@%s OK\n", r.Domain)
|
||||
|
||||
@@ -117,3 +117,22 @@ func TestRebuildInboundMapsEmptyOmitsPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two domains forwarded to one next hop share its TLS policy line: the map
|
||||
// holds the key once (audit 2026-10-12, L5).
|
||||
func TestRenderInboundMapsOneTLSLinePerNextHop(t *testing.T) {
|
||||
routes := []InboundRoute{
|
||||
{Domain: "b.example", Host: "10.0.0.8", Port: 25, TLSMode: "encrypt", RecipientMode: "any"},
|
||||
{Domain: "a.example", Host: "10.0.0.8", Port: 25, TLSMode: "encrypt", RecipientMode: "any"},
|
||||
}
|
||||
_, transport, _, tlsPolicy, err := renderInboundMaps(routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(transport) != "a.example smtp:[10.0.0.8]:25\nb.example smtp:[10.0.0.8]:25\n" {
|
||||
t.Errorf("transport = %q", transport)
|
||||
}
|
||||
if string(tlsPolicy) != "[10.0.0.8]:25 encrypt\n" {
|
||||
t.Errorf("tls_policy = %q, want the next hop once", tlsPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func socketsServer(t *testing.T) *Handlers {
|
||||
|
||||
func socketsOf(t *testing.T, h *Handlers) []health.Socket {
|
||||
t.Helper()
|
||||
return h.healthChecks()["Sockets"].([]health.Socket)
|
||||
return h.healthChecks().Sockets
|
||||
}
|
||||
|
||||
func socketCard(t *testing.T, h *Handlers) view.HealthCard {
|
||||
@@ -143,8 +143,8 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
}
|
||||
|
||||
c := h.healthChecks()
|
||||
if c["SocketStatus"].(health.Status) != tt.status {
|
||||
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c["SocketStatus"], tt.status)
|
||||
if c.SocketStatus != tt.status {
|
||||
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
|
||||
@@ -16,18 +16,18 @@ import (
|
||||
|
||||
// healthReading is one plausible reading of every check Health shows, so a test
|
||||
// can render the page and vary the one part it is about.
|
||||
func healthReading() map[string]any {
|
||||
func healthReading() healthReadings {
|
||||
c := checksFixture()
|
||||
c["Processes"] = []health.Process{
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
|
||||
}
|
||||
c["Cert"] = health.Certificate{
|
||||
c.Cert = health.Certificate{
|
||||
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
|
||||
NotAfter: time.Date(2026, 11, 8, 9, 14, 0, 0, time.UTC), DaysLeft: 30,
|
||||
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
|
||||
}
|
||||
c["Machine"] = health.Machine{
|
||||
c.Machine = health.Machine{
|
||||
CPU: health.CPU{
|
||||
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
|
||||
Status: health.StatusOK, Detail: "4 cores · 4 threads",
|
||||
@@ -43,13 +43,13 @@ func healthReading() map[string]any {
|
||||
Window: 5 * time.Second,
|
||||
Status: health.StatusOK,
|
||||
}
|
||||
c["Sockets"] = []health.Socket{
|
||||
c.Sockets = []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
{Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."},
|
||||
}
|
||||
c["SocketStatus"] = health.StatusWarn
|
||||
c["Hostname"] = "mail.example.com"
|
||||
c["PTR"] = dnscheck.Result{
|
||||
c.SocketStatus = health.StatusWarn
|
||||
c.Hostname = "mail.example.com"
|
||||
c.PTR = dnscheck.Result{
|
||||
Status: health.StatusError,
|
||||
Detail: "No address has a reverse record.",
|
||||
Records: []string{"203.0.113.10 → no PTR record"},
|
||||
@@ -57,7 +57,7 @@ func healthReading() map[string]any {
|
||||
return c
|
||||
}
|
||||
|
||||
func renderHealth(t *testing.T, h *Handlers, c map[string]any, flash string) string {
|
||||
func renderHealth(t *testing.T, h *Handlers, c healthReadings, flash string) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.view.Render(rec, 200, "health", healthPage(view.Meta{User: "admin", IsGlobal: true}, c, flash))
|
||||
@@ -111,7 +111,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
|
||||
func TestHealthPageWithoutMachineMetrics(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
c := healthReading()
|
||||
c["Machine"] = health.Machine{
|
||||
c.Machine = health.Machine{
|
||||
CPU: health.CPU{Status: health.StatusUnknown, Detail: "The kernel's processor counters (/proc/stat) could not be read here."},
|
||||
Memory: health.Memory{Status: health.StatusUnknown, Detail: "The kernel's memory counters (/proc/meminfo) could not be read here."},
|
||||
Network: health.Network{Status: health.StatusUnknown, Detail: "The kernel's network counters (/proc/net/dev) could not be read here."},
|
||||
@@ -131,12 +131,12 @@ func TestHealthPageWithoutMachineMetrics(t *testing.T) {
|
||||
func TestHealthPageStatesOfTheOtherChecks(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
c := healthReading()
|
||||
c["ProcessError"] = true
|
||||
c["ProcessStatus"] = health.StatusUnknown
|
||||
c["Processes"] = []health.Process(nil)
|
||||
c["Cert"] = health.Certificate{Status: health.StatusUnknown, Detail: "No certificate path is configured (TLS_CERT_FILE)."}
|
||||
c["Hostname"] = ""
|
||||
c["PTR"] = dnscheck.Result{Status: health.StatusUnknown}
|
||||
c.ProcessError = true
|
||||
c.ProcessStatus = health.StatusUnknown
|
||||
c.Processes = []health.Process(nil)
|
||||
c.Cert = health.Certificate{Status: health.StatusUnknown, Detail: "No certificate path is configured (TLS_CERT_FILE)."}
|
||||
c.Hostname = ""
|
||||
c.PTR = dnscheck.Result{Status: health.StatusUnknown}
|
||||
out := renderHealth(t, h, c, "DNS re-checked.")
|
||||
for _, want := range []string{
|
||||
"Could not ask supervisord for the process list.",
|
||||
@@ -155,13 +155,13 @@ func TestHealthPageStatesOfTheOtherChecks(t *testing.T) {
|
||||
func TestHealthPageNamesARenewalWaiting(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
c := healthReading()
|
||||
cert := c["Cert"].(health.Certificate)
|
||||
cert := c.Cert
|
||||
cert.Renewal = &health.Renewal{
|
||||
NotAfter: time.Date(2027, 1, 31, 8, 2, 0, 0, time.UTC),
|
||||
Mounted: time.Date(2026, 10, 15, 3, 12, 0, 0, time.UTC),
|
||||
}
|
||||
cert.Detail = "Postfix still serves the certificate above. A renewed one is waiting in the mounted file."
|
||||
c["Cert"] = cert
|
||||
c.Cert = cert
|
||||
out := renderHealth(t, h, c, "")
|
||||
for _, want := range []string{
|
||||
"<dt>Expires</dt>", "2026-11-08 09:14 UTC",
|
||||
|
||||
@@ -186,20 +186,20 @@ func senderCheckCard(s store.SenderCheckStats, now time.Time) (rows []view.Sende
|
||||
// healthPage turns one reading of the checks (healthChecks) into the Health
|
||||
// page: a table row per resource, process and socket, and the verdict of each
|
||||
// box in its head.
|
||||
func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
|
||||
func healthPage(m view.Meta, c healthReadings, flash string) *view.Health {
|
||||
page := view.NewHealth(m, flash)
|
||||
|
||||
machine := c["Machine"].(health.Machine)
|
||||
machine := c.Machine
|
||||
page.Machine.End = view.Verdict(string(machine.Status))
|
||||
page.MachineRows = machineRows(machine)
|
||||
|
||||
page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status)))
|
||||
page.ProcessError = c["ProcessError"].(bool)
|
||||
for _, p := range c["Processes"].([]health.Process) {
|
||||
page.Processes.End = view.Verdict(string(c.ProcessStatus))
|
||||
page.ProcessError = c.ProcessError
|
||||
for _, p := range c.Processes {
|
||||
page.ProcessRows = append(page.ProcessRows, processRow(p))
|
||||
}
|
||||
|
||||
cert := c["Cert"].(health.Certificate)
|
||||
cert := c.Cert
|
||||
page.Certificate.End = view.Verdict(string(cert.Status))
|
||||
if !cert.NotAfter.IsZero() {
|
||||
page.CertFacts = append(page.CertFacts, view.Fact{Label: "Expires", Value: view.Plain(cert.NotAfter.UTC().Format("2006-01-02 15:04 UTC")), Mono: true})
|
||||
@@ -214,8 +214,8 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
|
||||
}
|
||||
page.CertDetail, page.CertProblem = cert.Detail, cert.Status != health.StatusOK
|
||||
|
||||
page.Sockets.End = view.Verdict(string(c["SocketStatus"].(health.Status)))
|
||||
for _, s := range c["Sockets"].([]health.Socket) {
|
||||
page.Sockets.End = view.Verdict(string(c.SocketStatus))
|
||||
for _, s := range c.Sockets {
|
||||
row := view.SocketRow{Name: s.Name, Note: s.Note, Path: s.Path, State: view.Tag{Status: string(s.Status)}}
|
||||
if s.Status != health.StatusOK {
|
||||
row.Detail = s.Detail
|
||||
@@ -223,9 +223,9 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
|
||||
page.SocketRows = append(page.SocketRows, row)
|
||||
}
|
||||
|
||||
ptr := c["PTR"].(dnscheck.Result)
|
||||
ptr := c.PTR
|
||||
page.Hostname.End = view.Verdict(string(ptr.Status))
|
||||
host := c["Hostname"].(string)
|
||||
host := c.Hostname
|
||||
if host == "" {
|
||||
host = "(SELFPOST_HOSTNAME is not set)"
|
||||
}
|
||||
@@ -293,12 +293,12 @@ func lines(s string) []string {
|
||||
|
||||
// healthCards turns the checks into the six cards of Overview, in the order of
|
||||
// the tables on Health. Each card links to the table behind it.
|
||||
func healthCards(c map[string]any) []view.HealthCard {
|
||||
machine := c["Machine"].(health.Machine)
|
||||
procs := c["Processes"].([]health.Process)
|
||||
cert := c["Cert"].(health.Certificate)
|
||||
sockets := c["Sockets"].([]health.Socket)
|
||||
ptr := c["PTR"].(dnscheck.Result)
|
||||
func healthCards(c healthReadings) []view.HealthCard {
|
||||
machine := c.Machine
|
||||
procs := c.Processes
|
||||
cert := c.Cert
|
||||
sockets := c.Sockets
|
||||
ptr := c.PTR
|
||||
|
||||
card := func(name, icon, href string, status health.Status, value, sub string) view.HealthCard {
|
||||
return view.HealthCard{Name: name, Icon: icon, Href: href, Level: cardLevel(status), Value: value, Sub: sub}
|
||||
@@ -331,7 +331,7 @@ func healthCards(c map[string]any) []view.HealthCard {
|
||||
if len(notRunning) > 0 {
|
||||
procSub = strings.Join(notRunning, ", ")
|
||||
}
|
||||
if c["ProcessError"].(bool) {
|
||||
if c.ProcessError {
|
||||
procValue, procSub = "Could not be read", "supervisord"
|
||||
}
|
||||
|
||||
@@ -340,8 +340,8 @@ func healthCards(c map[string]any) []view.HealthCard {
|
||||
certValue = fmt.Sprintf("Expires in %d days", cert.DaysLeft)
|
||||
}
|
||||
|
||||
queueValue := queueCardValue(c["QueueSummary"].(string))
|
||||
if e := c["QueueError"].(string); e != "" {
|
||||
queueValue := queueCardValue(c.QueueSummary)
|
||||
if e := c.QueueError; e != "" {
|
||||
queueValue = "Could not be read"
|
||||
}
|
||||
|
||||
@@ -378,12 +378,12 @@ func healthCards(c map[string]any) []view.HealthCard {
|
||||
|
||||
return []view.HealthCard{
|
||||
card("Machine", "ti-cpu", "/server/health", machine.Status, cpu, ram),
|
||||
card("Processes", "ti-server-cog", "/server/health", c["ProcessStatus"].(health.Status), procValue, procSub),
|
||||
card("Processes", "ti-server-cog", "/server/health", c.ProcessStatus, procValue, procSub),
|
||||
card("TLS certificate", "ti-certificate", "/server/health", cert.Status, certValue, cert.Subject),
|
||||
card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"),
|
||||
card("Milter sockets", "ti-plug-connected", "/server/health", c["SocketStatus"].(health.Status),
|
||||
card("Queue", "ti-stack-2", "/outbound/queue", c.QueueStatus, queueValue, "outbound"),
|
||||
card("Milter sockets", "ti-plug-connected", "/server/health", c.SocketStatus,
|
||||
fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub),
|
||||
card("Reverse DNS", "ti-arrows-exchange", "/server/health", ptr.Status, ptrValue, c["Hostname"].(string)),
|
||||
card("Reverse DNS", "ti-arrows-exchange", "/server/health", ptr.Status, ptrValue, c.Hostname),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,8 +417,26 @@ func cardLevel(s health.Status) string {
|
||||
}
|
||||
}
|
||||
|
||||
// healthReadings are what Health and Overview are drawn from, read once per
|
||||
// page. A struct, not a map: a mistyped name is a compile error and not an
|
||||
// empty card (owner, 2026-10-13; the last of template-data-typing).
|
||||
type healthReadings struct {
|
||||
Processes []health.Process
|
||||
ProcessError bool
|
||||
ProcessStatus health.Status
|
||||
QueueSummary string
|
||||
QueueError string
|
||||
QueueStatus health.Status
|
||||
Machine health.Machine
|
||||
Cert health.Certificate
|
||||
Sockets []health.Socket
|
||||
SocketStatus health.Status
|
||||
Hostname string
|
||||
PTR dnscheck.Result
|
||||
}
|
||||
|
||||
// healthChecks runs every server check once.
|
||||
func (h *Handlers) healthChecks() map[string]any {
|
||||
func (h *Handlers) healthChecks() healthReadings {
|
||||
procs, procErr := health.Processes()
|
||||
procStatus := health.StatusUnknown
|
||||
if procErr != nil {
|
||||
@@ -452,19 +470,19 @@ func (h *Handlers) healthChecks() map[string]any {
|
||||
machine := h.machine.Sample()
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
|
||||
return map[string]any{
|
||||
"Processes": procs,
|
||||
"ProcessError": procErr != nil,
|
||||
"ProcessStatus": procStatus,
|
||||
"QueueSummary": queueSummary(queueText),
|
||||
"QueueError": queueErr,
|
||||
"QueueStatus": queueStatus,
|
||||
"Machine": machine,
|
||||
"Cert": cert,
|
||||
"Sockets": sockets,
|
||||
"SocketStatus": socketStatus,
|
||||
"Hostname": h.cfg.Hostname,
|
||||
"PTR": srv.PTR,
|
||||
return healthReadings{
|
||||
Processes: procs,
|
||||
ProcessError: procErr != nil,
|
||||
ProcessStatus: procStatus,
|
||||
QueueSummary: queueSummary(queueText),
|
||||
QueueError: queueErr,
|
||||
QueueStatus: queueStatus,
|
||||
Machine: machine,
|
||||
Cert: cert,
|
||||
Sockets: sockets,
|
||||
SocketStatus: socketStatus,
|
||||
Hostname: h.cfg.Hostname,
|
||||
PTR: srv.PTR,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,26 +10,26 @@ import (
|
||||
|
||||
// checksFixture is one reading of every server check, shaped as healthChecks
|
||||
// returns it.
|
||||
func checksFixture() map[string]any {
|
||||
return map[string]any{
|
||||
"Processes": []health.Process{
|
||||
func checksFixture() healthReadings {
|
||||
return healthReadings{
|
||||
Processes: []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Status: health.StatusError},
|
||||
},
|
||||
"ProcessError": false,
|
||||
"ProcessStatus": health.StatusError,
|
||||
"QueueSummary": "Mail queue is empty",
|
||||
"QueueError": "",
|
||||
"QueueStatus": health.StatusOK,
|
||||
"Machine": health.Machine{Status: health.StatusUnknown},
|
||||
"Cert": health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
|
||||
"Sockets": []health.Socket{
|
||||
ProcessError: false,
|
||||
ProcessStatus: health.StatusError,
|
||||
QueueSummary: "Mail queue is empty",
|
||||
QueueError: "",
|
||||
QueueStatus: health.StatusOK,
|
||||
Machine: health.Machine{Status: health.StatusUnknown},
|
||||
Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
|
||||
Sockets: []health.Socket{
|
||||
{Name: "OpenDKIM", Present: true, Status: health.StatusOK},
|
||||
{Name: "send-log", Present: false, Status: health.StatusWarn},
|
||||
},
|
||||
"SocketStatus": health.StatusWarn,
|
||||
"Hostname": "mail.example.org",
|
||||
"PTR": dnscheck.Result{Status: health.StatusOK},
|
||||
SocketStatus: health.StatusWarn,
|
||||
Hostname: "mail.example.org",
|
||||
PTR: dnscheck.Result{Status: health.StatusOK},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestHealthCards(t *testing.T) {
|
||||
|
||||
// A check that could not run must not look fine.
|
||||
c := checksFixture()
|
||||
c["ProcessError"] = true
|
||||
c["ProcessStatus"] = health.StatusUnknown
|
||||
c["PTR"] = dnscheck.Result{Status: health.StatusUnknown}
|
||||
c.ProcessError = true
|
||||
c.ProcessStatus = health.StatusUnknown
|
||||
c.PTR = dnscheck.Result{Status: health.StatusUnknown}
|
||||
cards = healthCards(c)
|
||||
if cards[1].Level != "warn" || cards[1].Value != "Could not be read" || cards[5].Level != "warn" || cards[5].Value != "Not checked" {
|
||||
t.Errorf("unverified checks render as %+v and %+v", cards[1], cards[5])
|
||||
@@ -86,18 +86,18 @@ func TestQueueCardValue(t *testing.T) {
|
||||
// reload helper idles between runs: it is neither running nor missing.
|
||||
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
|
||||
c := checksFixture()
|
||||
c["Processes"] = []health.Process{
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
|
||||
}
|
||||
c["ProcessStatus"] = health.StatusOK
|
||||
c.ProcessStatus = health.StatusOK
|
||||
card := healthCards(c)[1]
|
||||
if card.Value != "2 of 2 running" || card.Sub != "all programs" {
|
||||
t.Errorf("card = %q / %q, want 2 of 2 running / all programs", card.Value, card.Sub)
|
||||
}
|
||||
|
||||
c["Processes"] = []health.Process{
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user