Compare commits

..
3 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 403ce93990 release: 2.0.0: Panel redesign; new URLs; database from zero
test / test (push) Canceled after 0s
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.

Since the last cut also fixed, all found on the test server:
  - the panel can read the mail queue;
  - port 25 answers with DMARC report ingest on, also next to the
    inbound relay;
  - DMARC reports reach the panel (the delivery service never worked
    since 1.7.0);
  - Postfix start-up warnings about the queue directory.

Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.

On the test server, on 252e5d1: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:32:08 +03:00
mixandClaude Opus 5.5 252e5d1cff dmarc-ingest: Postfix can run the command, and it is told the recipient
The first DMARC report to reach the test server (Google, 2026-10-11
09:41 UTC) was accepted on port 25 and then sat in the queue:

  postfix/pipe: fatal: missing user= command-line attribute
  postfix/qmgr: warning: private/dmarc-ingest socket: malformed response

Three things stood between an accepted report and the panel, all since
the feature shipped in 1.7.0 - no report had ever been delivered:

- The service was addressed as dmarc-ingest/unix-pipe. Its type is unix:
  postconf -P answered "unmatched service_name/type" for flags=, user=
  and argv= (the warnings in the container log at every start), which
  are arguments of pipe(8) anyway and not -o parameters, and the service
  stayed a bare "pipe". For the same reason postconf -MX never removed
  it. It is dmarc-ingest/unix now, with the arguments in the service line.
- pipe(8) gives the command export_environment and nothing else, so the
  command would have refused to run: it reads DMARC_REPORTS_ENABLE (and
  the data directory, when it is not the default) from the environment.
  Those are now named in export_environment.
- The command took the recipient from RECIPIENT, which local(8) sets and
  pipe(8) does not. With no recipient the check that a report came to the
  address of its own domain is skipped. It is now ${recipient} in argv,
  one recipient per run.

When DMARC ingest is off the service and both parameters are removed.

e2e: every port-25 combination now asks Postfix for the service and the
two parameters (postconf -M / -h), and for their absence when ingest is
off - which is how the wrong type was found. make e2e on the test
server: ok. go test ./cmd/panel/ ./internal/dmarc/ ./internal/postfix/:
ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:25:14 +03:00
mixandClaude Opus 5.5 5af4d6c368 panel-redesign: the health cards of Overview carry no anchor
Follows the design commit before it: Machine, Processes, TLS certificate, Milter sockets and Reverse DNS lead to /server/health as it opens; so does the link in the Needs attention line, which takes its address from the card.

  go vet ./internal/web/...: ok
  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:17 +03:00
17 changed files with 157 additions and 43 deletions
+21
View File
@@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.0.0] - 2026-10-11
The panel redesign. A breaking release: every panel URL changed and the
database starts from zero - a 1.x `/data` is not upgraded (see Changed and
Removed).
### Added ### Added
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons - panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
@@ -233,6 +239,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
restriction list, and the report addresses - on the server's own name, a restriction list, and the report addresses - on the server's own name, a
relay domain - were not in `relay_recipient_maps`. Their table is a relay domain - were not in `relay_recipient_maps`. Their table is a
recipient table now. Hidden until now by the port-25 failure above. recipient table now. Hidden until now by the port-25 failure above.
- **DMARC reports reach the panel.** A report accepted on port 25 stayed in
the queue: the generated delivery service was a bare `pipe` ("fatal:
missing user= command-line attribute"), because the generator addressed it
under a type Postfix does not have and set `flags=`, `user=` and `argv=`
as `-o` parameters, which they are not. Behind that, the command would
have refused to run (Postfix starts it without the container's
environment) and was never told the recipient, so the check that a report
came to the address of its own domain was skipped. The service line
carries its arguments, the recipient is one of them, and
`export_environment` names what the command reads. No report had been
delivered since the feature shipped in 1.7.0; the e2e check asks Postfix
how it reads the service.
- Postfix no longer logs "warning: not owned by root" for the queue
directory and its `pid/` at every start: the entrypoint hands the two back
to root after its recursive change of owner.
- Overview's Processes card counts the programs that are meant to stay up - Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as ("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
running. The Milter sockets card no longer clips its second line with three running. The Milter sockets card no longer clips its second line with three
+1 -1
View File
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.9.5 ghcr.io/mixeme/selfpost:2.0.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+20 -6
View File
@@ -285,11 +285,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
[ -e "$f" ] || : > "$f" [ -e "$f" ] || : > "$f"
done done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe" # flags=, user= and argv= are arguments of pipe(8), not -o parameters:
postconf -P \ # postconf -P refuses them ("unmatched service_name/type") and leaves a
"dmarc-ingest/unix-pipe/flags=Rq" \ # bare "pipe", which dies with "fatal: missing user= command-line
"dmarc-ingest/unix-pipe/user=panel" \ # attribute" on every report. They go into the service line itself.
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest" # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there.
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
if [ -n "$RELAY_DOMAINS_SETTING" ]; then if [ -n "$RELAY_DOMAINS_SETTING" ]; then
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
@@ -300,6 +310,9 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# Comma, not a space, between check_recipient_access and its table: see # Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o. # master_cf_set_smtp_inet_o.
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
fi fi
postconf -e \ postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \ "relay_domains=${RELAY_DOMAINS_SETTING}" \
@@ -381,7 +394,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
else else
postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
postconf -e \ postconf -e \
"relay_domains=" \ "relay_domains=" \
"transport_maps=" \ "transport_maps=" \
+16 -4
View File
@@ -26,10 +26,7 @@ func runDMARCIngestMode() error {
} }
defer st.Close() defer st.Close()
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT"))) recipient := ingestRecipient(os.Args[1:])
if recipient == "" {
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
}
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr) log.Printf("dmarc ingest: record failure: %v", incrErr)
@@ -39,6 +36,21 @@ func runDMARCIngestMode() error {
return nil return nil
} }
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
for _, a := range args {
if a == "-dmarc-ingest" {
continue
}
return strings.ToLower(strings.TrimSpace(a))
}
return ""
}
func isDMARCIngestInvocation() bool { func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" { if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true return true
+20
View File
@@ -0,0 +1,20 @@
package main
import "testing"
func TestIngestRecipient(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
// As pipe(8) starts it: the symlink name, then ${recipient}.
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest"}, ""},
{nil, ""},
} {
if got := ingestRecipient(tc.args); got != tc.want {
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.9.5 image: ghcr.io/mixeme/selfpost:2.0.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+2 -2
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `1.9.5`; adjust if semver changes mid-track): **Planned version cuts** (from pin `2.0.0`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -123,7 +123,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) | | 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) | | 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) | | 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
| 13 | panel-redesign | `2.0.0` (planned; MAJOR, no compatibility) | | 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) |
| 14 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) | | 14 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) |
Docs-only prep (checklists in plans, no product code) uses the same per-step Docs-only prep (checklists in plans, no product code) uses the same per-step
+1 -1
View File
@@ -183,7 +183,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.9.5`. Intermediate deliberately never `:latest`. The current pin is `2.0.0`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
+2 -2
View File
@@ -1,6 +1,6 @@
# Plan: panel-redesign # Plan: panel-redesign
**Status:** agreed (owner, 2026-10-09) **Status:** shipped in `2.0.0` (2026-10-11)
**Date:** 2026-09-21 (replaces the 2026-09-08 givens) **Date:** 2026-09-21 (replaces the 2026-09-08 givens)
**Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility). **Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility).
@@ -532,7 +532,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Technical review of the whole against the mockups, evidence pairs complete — **Opus** - [x] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable** - [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet** - [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
- [ ] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku** - [x] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku**
**After 2.0** — features land in the kit, each from its own plan, mockup first **After 2.0** — features land in the kit, each from its own plan, mockup first
(§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever (§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever
+4 -4
View File
@@ -39,12 +39,12 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 26/27 | [plans/panel-redesign.md](plans/panel-redesign.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) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): the next stage is **panel-redesign** (`2.0.0`), development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11); the next stage is
then **inbound-antispam-panel** (`2.1.0`) built in the new component kit — **inbound-antispam-panel** (`2.1.0`), built in the new component kit —
styling its two screens once instead of twice. panel-docs shipped in styling its two screens once instead of twice. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]` [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(security review of the ingest path pending); domain-stats-auto-ratelimit in (security review of the ingest path pending); domain-stats-auto-ratelimit in
@@ -438,7 +438,7 @@ of their own — answered by guard tests that land before the templates, a
`design-first` CI step and evidence-based acceptance (plan § Enforcement). `design-first` CI step and evidence-based acceptance (plan § Enforcement).
Competes with feature work; Bulma is ~680 kB minified, embedded once. Competes with feature work; Bulma is ~680 kB minified, embedded once.
**Version:** `2.0.0` MAJOR; `agreed` (owner, 2026-10-09). **Version:** shipped `2.0.0` (2026-10-11), MAJOR.
--- ---
@@ -201,7 +201,7 @@ func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
`<h1 class="title is-3">`, `class="sp-postmark`, `<p class="sp-kicker">mail.example.org</p>`, `<h1 class="title is-3">`, `class="sp-postmark`, `<p class="sp-kicker">mail.example.org</p>`,
`href="/server/health#certificate"`, `href="/server/health#sockets"`, `href="/outbound/queue"`, `href="/server/health"`, `href="/server/health"`, `href="/outbound/queue"`,
`<h2>Outbound domains</h2>`, `<a href="/outbound/domains/1"><strong>example.org</strong></a>`, `<h2>Outbound domains</h2>`, `<a href="/outbound/domains/1"><strong>example.org</strong></a>`,
"DKIM", "SPF", "DMARC", "1\u00a0msg", `<th>30 days</th>`, "DKIM", "SPF", "DMARC", "1\u00a0msg", `<th>30 days</th>`,
`href="/server/health"`, `href="/server/health"`,
+5 -5
View File
@@ -326,13 +326,13 @@ func healthCards(c map[string]any) []view.HealthCard {
} }
return []view.HealthCard{ return []view.HealthCard{
card("Machine", "ti-cpu", "/server/health#machine", machine.Status, cpu, ram), card("Machine", "ti-cpu", "/server/health", machine.Status, cpu, ram),
card("Processes", "ti-server-cog", "/server/health#processes", c["ProcessStatus"].(health.Status), procValue, procSub), card("Processes", "ti-server-cog", "/server/health", c["ProcessStatus"].(health.Status), procValue, procSub),
card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject), 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("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"),
card("Milter sockets", "ti-plug-connected", "/server/health#sockets", c["SocketStatus"].(health.Status), card("Milter sockets", "ti-plug-connected", "/server/health", c["SocketStatus"].(health.Status),
fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub), fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub),
card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)), card("Reverse DNS", "ti-arrows-exchange", "/server/health", ptr.Status, ptrValue, c["Hostname"].(string)),
} }
} }
@@ -38,12 +38,12 @@ func checksFixture() map[string]any {
func TestHealthCards(t *testing.T) { func TestHealthCards(t *testing.T) {
cards := healthCards(checksFixture()) cards := healthCards(checksFixture())
want := []struct{ name, level, value, sub, href string }{ want := []struct{ name, level, value, sub, href string }{
{"Machine", "warn", "No reading", "", "/server/health#machine"}, {"Machine", "warn", "No reading", "", "/server/health"},
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"}, {"Processes", "fail", "1 of 2 running", "postfix", "/server/health"},
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"}, {"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health"},
{"Queue", "", "Empty", "outbound", "/outbound/queue"}, {"Queue", "", "Empty", "outbound", "/outbound/queue"},
{"Milter sockets", "warn", "1 of 2 answering", "send-log", "/server/health#sockets"}, {"Milter sockets", "warn", "1 of 2 answering", "send-log", "/server/health"},
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health#hostname"}, {"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health"},
} }
if len(cards) != len(want) { if len(cards) != len(want) {
t.Fatalf("%d cards, want %d", len(cards), len(want)) t.Fatalf("%d cards, want %d", len(cards), len(want))
+5 -5
View File
@@ -250,12 +250,12 @@ func TestBoxFootVariants(t *testing.T) {
func TestHealthCards(t *testing.T) { func TestHealthCards(t *testing.T) {
out := squash(partial(t, "health_cards", []HealthCard{ out := squash(partial(t, "health_cards", []HealthCard{
{Name: "Machine", Value: "CPU 12 %", Sub: "RAM 41 %", Icon: "ti-cpu", Href: "/server/health#machine"}, {Name: "Machine", Value: "CPU 12 %", Sub: "RAM 41 %", Icon: "ti-cpu", Href: "/server/health"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "renewal has not run", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn}, {Name: "TLS certificate", Value: "Expires in 12 days", Sub: "renewal has not run", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
{Name: "Sockets", Value: "1 of 2", Sub: "x", Icon: "ti-plug-connected-x", Href: "/server/health#sockets", Level: LevelFail}, {Name: "Sockets", Value: "1 of 2", Sub: "x", Icon: "ti-plug-connected-x", Href: "/server/health", Level: LevelFail},
})) }))
mustContain(t, out, `<div class="sp-health"><a href="/server/health#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 %</span></span></a>`, mustContain(t, out, `<div class="sp-health"><a href="/server/health"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 %</span></span></a>`,
`<a href="/server/health#certificate" class="sp-warn">`, `<a href="/server/health#sockets" class="sp-fail">`) `<a href="/server/health" class="sp-warn">`, `<a href="/server/health" class="sp-fail">`)
} }
func TestDNSRecordShowsInDNSNowOnlyOnAMismatch(t *testing.T) { func TestDNSRecordShowsInDNSNowOnlyOnAMismatch(t *testing.T) {
+5 -5
View File
@@ -61,12 +61,12 @@ func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
// checks, three outbound domains and two inbound. // checks, three outbound domains and two inbound.
func overviewFixture() *Overview { func overviewFixture() *Overview {
cards := []HealthCard{ cards := []HealthCard{
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health#machine"}, {Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health"},
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health#processes"}, {Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn}, {Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"}, {Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health#sockets"}, {Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health"},
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health#hostname"}, {Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health"},
} }
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true) o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
dns := func(dkim, spf, dmarc string) []Tag { dns := func(dkim, spf, dmarc string) []Tag {
+1 -1
View File
@@ -61,7 +61,7 @@ func TestOverviewHeadFollowsTheCards(t *testing.T) {
pageHas(t, "Overview", out, pageHas(t, "Overview", out,
`<h1 class="title is-3">Running, with warnings.</h1>`, `<h1 class="title is-3">Running, with warnings.</h1>`,
`class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`, `class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`,
`Needs attention: <a href="/server/health#certificate">TLS certificate</a> (Expires in 12 days).`, `Needs attention: <a href="/server/health">TLS certificate</a> (Expires in 12 days).`,
`Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`, `Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`,
`class="sp-warn"`, // the certificate card `class="sp-warn"`, // the certificate card
`href="/server/health"`, `>All details</a>`, `href="/server/health"`, `>All details</a>`,
+47
View File
@@ -25,8 +25,17 @@ type port25Case struct {
// main are main.cf parameters the combination must end up with, read with // main are main.cf parameters the combination must end up with, read with
// postconf -h. // postconf -h.
main []optionCheck main []optionCheck
// ingest is set when the combination must carry the DMARC report delivery
// service, with everything the command it runs depends on.
ingest bool
} }
// ingestService is the dmarc-ingest service as Postfix must read it back.
// flags=, user= and argv= are arguments of pipe(8); set as -o parameters they
// are dropped, and a bare "pipe" dies on every report with "fatal: missing
// user= command-line attribute" - the state of 1.7.0 through 1.9.5.
const ingestService = "dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}"
var port25Cases = []port25Case{ var port25Cases = []port25Case{
{ {
name: "inbound relay only", name: "inbound relay only",
@@ -53,6 +62,7 @@ var port25Cases = []port25Case{
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject", wantExact: true}, {param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject", wantExact: true},
{param: "smtpd_milters", contains: "", wantExact: true}, {param: "smtpd_milters", contains: "", wantExact: true},
}, },
ingest: true,
}, },
{ {
name: "inbound relay and dmarc ingest", name: "inbound relay and dmarc ingest",
@@ -71,6 +81,7 @@ var port25Cases = []port25Case{
main: []optionCheck{ main: []optionCheck{
{param: "relay_recipient_maps", contains: "texthash:/data/postfix/relay_recipients texthash:/data/postfix/dmarc_recipients", wantExact: true}, {param: "relay_recipient_maps", contains: "texthash:/data/postfix/relay_recipients texthash:/data/postfix/dmarc_recipients", wantExact: true},
}, },
ingest: true,
}, },
{ {
name: "inbound relay with antispam milter", name: "inbound relay with antispam milter",
@@ -148,6 +159,9 @@ func checkPort25ConfigCombinations(s *stack) error {
return fmt.Errorf("%s: %s = %q, want %q", tc.name, c.param, got, c.contains) return fmt.Errorf("%s: %s = %q, want %q", tc.name, c.param, got, c.contains)
} }
} }
if err := checkIngestService(s, tc); err != nil {
return err
}
} }
// Back to the stand's own configuration: flags off, no port-25 listener. // Back to the stand's own configuration: flags off, no port-25 listener.
@@ -157,6 +171,39 @@ func checkPort25ConfigCombinations(s *stack) error {
return checkInboundRelayOff(s) return checkInboundRelayOff(s)
} }
// checkIngestService asks Postfix for the dmarc-ingest service and the two
// main.cf parameters its command depends on: one recipient per run, so
// ${recipient} is one argument, and DMARC_REPORTS_ENABLE in the environment
// pipe(8) gives the command - it passes export_environment and nothing else.
// A combination without DMARC ingest must have none of the three left over.
func checkIngestService(s *stack, tc port25Case) error {
service, _ := s.execIn("selfpost", "postconf", "-M", "dmarc-ingest/unix")
service = strings.Join(strings.Fields(service), " ")
limit, _ := s.execIn("selfpost", "postconf", "-h", "dmarc-ingest_destination_recipient_limit")
limit = strings.TrimSpace(limit)
environ, err := s.execIn("selfpost", "postconf", "-h", "export_environment")
if err != nil {
return fmt.Errorf("%s: postconf -h export_environment: %v\n%s", tc.name, err, environ)
}
exported := strings.Contains(" "+strings.TrimSpace(environ)+" ", " DMARC_REPORTS_ENABLE=true ")
if !tc.ingest {
if strings.Contains(service, "pipe") || limit == "1" || exported {
return fmt.Errorf("%s: DMARC ingest is off but left behind: service %q, recipient limit %q, export_environment %q", tc.name, service, limit, environ)
}
return nil
}
if service != ingestService {
return fmt.Errorf("%s: Postfix reads the dmarc-ingest service as %q, want %q", tc.name, service, ingestService)
}
if limit != "1" {
return fmt.Errorf("%s: dmarc-ingest_destination_recipient_limit = %q, want 1", tc.name, limit)
}
if !exported {
return fmt.Errorf("%s: export_environment = %q, want DMARC_REPORTS_ENABLE=true in it", tc.name, environ)
}
return nil
}
// runPostfixConfig re-runs the config generator with env overrides. The script // runPostfixConfig re-runs the config generator with env overrides. The script
// ends with `postfix check`, so a non-zero exit is exactly the start-time // ends with `postfix check`, so a non-zero exit is exactly the start-time
// failure an operator would hit. // failure an operator would hit.