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:
@@ -73,6 +73,23 @@ Migration `0011` (a table, nothing existing changes).
|
||||
next check sends it to those who did not get it, once; a user added while
|
||||
the problem is open is not mailed about it; "it is over" goes to those
|
||||
who were told.
|
||||
- **A DNS resolver that dropped packets made the checks report records as
|
||||
wrong.** The first resolver had the whole check's five seconds, so the
|
||||
others were asked with no time left; a lookup that was not answered then
|
||||
read as "does not resolve" or "no PTR record", and the server's addresses
|
||||
were dropped, so every domain's SPF went unknown with it. Each resolver
|
||||
now has 2 seconds of its own and the one that answered last is asked
|
||||
first; an unanswered lookup is *unknown*, the addresses the last answer
|
||||
gave are kept, and notifications report what is wrong, never what is not
|
||||
known (audit 2026-10-12, M14, M15).
|
||||
- **A DMARC report too large was refused as a broken one.** A message over
|
||||
10 MiB, or a report that unpacked past 8 MiB, was cut at the limit and then
|
||||
failed as damaged; it is now refused for its size, and `mail.log` says so.
|
||||
- **Two inbound domains to one upstream with different TLS modes** wrote two
|
||||
policy lines for one next hop, and Postfix used the first; the form's
|
||||
choice for the other domain did nothing. Saving the second is refused,
|
||||
naming the domain whose mode the upstream has, and the policy map holds
|
||||
each next hop once (audit 2026-10-12, L5).
|
||||
|
||||
## [2.5.3] - 2026-10-15
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,8 +13,6 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||
|
||||
func runDMARCIngestMode() error {
|
||||
cfg := loadConfig()
|
||||
if !cfg.dmarcEnabled {
|
||||
@@ -28,7 +25,7 @@ func runDMARCIngestMode() error {
|
||||
defer st.Close()
|
||||
|
||||
recipient := ingestRecipient(os.Args[1:])
|
||||
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
|
||||
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
|
||||
if !errors.Is(err, dmarc.ErrRejected) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1326,7 +1326,7 @@ command whose leftover is the owner's alone.
|
||||
retried - the subject is gone with it, as before. Tests: a failed copy
|
||||
goes out at the next check, once; the ceiling holds one back; a user
|
||||
added mid-subject hears nothing of it, its end included.
|
||||
- [ ] **A DNS outage is "unknown", not "wrong"** (M14, M15; owner,
|
||||
- [x] **A DNS outage is "unknown", not "wrong"** (M14, M15; owner,
|
||||
2026-10-14 - the two findings had no step). Each resolver gets a time
|
||||
budget of its own, so a silent first resolver no longer leaves the second
|
||||
an expired context ([dnscheck/resolver.go](../internal/dnscheck/resolver.go));
|
||||
@@ -1338,7 +1338,17 @@ command whose leftover is the owner's alone.
|
||||
and tested. Tests: a first resolver that drops packets, a PTR timeout.
|
||||
architecture.md:339-340 and the `DefaultResolvers` comment say what is
|
||||
true. — **Opus**
|
||||
- [ ] **DMARC reports tested where they come in** (owner, 2026-10-14; rows of
|
||||
*Done 2026-10-15:* `queryEach` gives each resolver
|
||||
`perResolverTimeout` (2 s) within the check's context and asks the one
|
||||
that answered last first (a dropping first resolver costs one 2 s wait,
|
||||
then is asked last); an address or PTR lookup not answered is Unknown
|
||||
(`lookupFailed`), an answered mismatch beside it still the error it is;
|
||||
`Checker.Server` keeps the last answer's addresses when the lookup was
|
||||
not answered. `notify.dnsReading` already reported faults only - now
|
||||
tested with SPF unknown alone and beside a wrong DKIM. architecture.md
|
||||
no longer has the sentence the audit cited; the guide's
|
||||
`SELFPOST_DNS_RESOLVERS` row says the budget and the order.
|
||||
- [x] **DMARC reports tested where they come in** (owner, 2026-10-14; rows of
|
||||
review-2026-08-followups). The panel parses reports that arrive by mail
|
||||
from anyone, and the `internal/dmarc` service (`service.go`,
|
||||
`addresses.go`) and the DMARC handlers have no tests; no other step
|
||||
@@ -1346,18 +1356,37 @@ command whose leftover is the owner's alone.
|
||||
oversized, compressed past its limit, or names a domain the instance does
|
||||
not have: refused or skipped with a reason, nothing stored, the panel
|
||||
alive. A finding is fixed in this step. — **Opus**
|
||||
- [ ] **The last untyped page data** (owner, 2026-10-13): `healthChecks`
|
||||
*Done 2026-10-15.* Since the audit the package had gained tests (bounds,
|
||||
unknown domain, tag mismatch, not a report, a store failure, the
|
||||
allow-list) and the handlers theirs. Added: malformed XML, a gzip bomb
|
||||
and a zip bomb (9 MiB of zeroes), a message over the limit - each
|
||||
refused with its reason, nothing stored; the service's Resync on and
|
||||
off, the hosted suggestion and its validation. The finding: both limits
|
||||
cut silently (`io.LimitReader`), so an oversized report failed as
|
||||
damaged; `readAtMost` refuses it for its size (`MaxMessage` 10 MiB, the
|
||||
message limit moved from cmd/panel into `IngestMessage`). The panel
|
||||
is not in the path: ingest is its own process per message, and a
|
||||
refusal exits 0 with the reason in `mail.log`.
|
||||
- [x] **The last untyped page data** (owner, 2026-10-13): `healthChecks`
|
||||
([handlers_status.go](../internal/web/handlers/handlers_status.go)) returns
|
||||
a struct instead of `map[string]any`, so a mistyped key in `healthPage` /
|
||||
`healthCards` is a compile error and not an empty card on Server › Health.
|
||||
No template change. This closes what is left of the roadmap's former
|
||||
`template-data-typing`. — **Sonnet**
|
||||
- [ ] **Upstream TLS mode validated at save** (L5): saving an inbound domain
|
||||
*Done 2026-10-15:* `healthReadings`, read by `healthPage` and
|
||||
`healthCards` and the tests' fixtures; no `map[string]any` is left in
|
||||
internal/web outside tests.
|
||||
- [x] **Upstream TLS mode validated at save** (L5): saving an inbound domain
|
||||
whose `[host]:port` is already used by another domain with a different
|
||||
TLS mode is refused with the validator's text naming that domain and its
|
||||
mode; `TestRenderInboundMaps` gets the two-routes-one-next-hop case and the
|
||||
map is asserted to hold one key. Guide § Inbound domains says a next hop
|
||||
has one TLS mode. — **Opus**
|
||||
*Done 2026-10-15:* `inbound.Service.checkOneTLSModePerUpstream` (the
|
||||
domain itself may change its own mode; another port is another next
|
||||
hop); `renderInboundMaps` writes a next hop's policy line once (rows
|
||||
saved before keep the first by domain name, as Postfix did). Tests in
|
||||
inbound and postfix (`TestRenderInboundMapsOneTLSLinePerNextHop`).
|
||||
|
||||
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
|
||||
|
||||
|
||||
+5
-2
@@ -235,7 +235,7 @@ expected to set; defaults match the code exactly.
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||||
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
|
||||
|
||||
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||
@@ -1015,7 +1015,10 @@ reports](#dmarc-reports).) Outbound 465/587 is unchanged.
|
||||
|
||||
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
|
||||
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
|
||||
required / off), and choose recipients — an allow-list, or any address at that
|
||||
required / off), and choose recipients. An upstream has one TLS mode: Postfix
|
||||
keeps the TLS policy per next hop, not per domain, so a second domain sent to
|
||||
the same host and port with another mode is refused, naming the domain that
|
||||
already uses it. Recipients are an allow-list, or any address at that
|
||||
domain. Inbound domains are assigned to a user with the *domain* role
|
||||
separately from outbound ones (see [Users](#users)); such a user sees and
|
||||
changes the upstream and recipients of the inbound domains assigned to them,
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 64/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 68/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
|
||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||
|
||||
@@ -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