Compare commits
172
Commits
v2.0.0
..
a07ba5776c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a07ba5776c | ||
|
|
a556a13f67 | ||
|
|
a27f8332ab | ||
|
|
466a3d2089 | ||
|
|
3b440c2a53 | ||
|
|
a3050afb66 | ||
|
|
1c6e04b7d3 | ||
|
|
68ec8b274c | ||
|
|
b76f1ef1a0 | ||
|
|
aaa94120be | ||
|
|
2c8aec79b4 | ||
|
|
6023feb405 | ||
|
|
dc78b0ed76 | ||
|
|
95af363214 | ||
|
|
b09e525521 | ||
|
|
a39eda5a0b | ||
|
|
dab7715e80 | ||
|
|
d6ed6bcb30 | ||
|
|
eaa7de0754 | ||
|
|
32dfe4a2cf | ||
|
|
006bed7022 | ||
|
|
eee089ad4b | ||
|
|
b90a2f0321 | ||
|
|
2b8e0c086d | ||
|
|
80e3979748 | ||
|
|
ca876c1631 | ||
|
|
643892ab91 | ||
|
|
07be0cfb58 | ||
|
|
48832d223a | ||
|
|
76b21b3597 | ||
|
|
242515c305 | ||
|
|
508ac96c79 | ||
|
|
8e3241ef98 | ||
|
|
68b3a7b23f | ||
|
|
9eddd49602 | ||
|
|
ec08c8606b | ||
|
|
3584adcbc2 | ||
|
|
2cf118cafc | ||
|
|
cad1182150 | ||
|
|
fbd0acd035 | ||
|
|
2e1544dac5 | ||
|
|
3c63ecc1ad | ||
|
|
9ca50d33fd | ||
|
|
98e09fef4a | ||
|
|
ea8b6bfa6d | ||
|
|
d20009c0f0 | ||
|
|
f2ad699473 | ||
|
|
ee37d16dc7 | ||
|
|
463d4ff3fe | ||
|
|
e174537512 | ||
|
|
c8f669714e | ||
|
|
0ed03b2094 | ||
|
|
55aef6e721 | ||
|
|
31c2b50457 | ||
|
|
a658da11f7 | ||
|
|
599149bf83 | ||
|
|
ad3d834392 | ||
|
|
b113e72c71 | ||
|
|
ce363f48dd | ||
|
|
a8b6276b0d | ||
|
|
811055d4c0 | ||
|
|
0f984ff8d8 | ||
|
|
3c8628074f | ||
|
|
e1ddf12744 | ||
|
|
af130e268a | ||
|
|
1ca87f2d1a | ||
|
|
5dcff8c2b9 | ||
|
|
57142b21b5 | ||
|
|
5154bba61c | ||
|
|
415334c2cc | ||
|
|
ac985d03a8 | ||
|
|
df96690ba6 | ||
|
|
65d916f26a | ||
|
|
e166a2ebd6 | ||
|
|
c452606175 | ||
|
|
4678015bbd | ||
|
|
5be109b871 | ||
|
|
ef18ef21a0 | ||
|
|
8b53d63160 | ||
|
|
cb5a4a22e1 | ||
|
|
5747f0b25c | ||
|
|
a618ffb0e4 | ||
|
|
e469034477 | ||
|
|
f9e75de8d1 | ||
|
|
60c7b85f84 | ||
|
|
f51f5db245 | ||
|
|
a59e3d4d61 | ||
|
|
db36aa2c0e | ||
|
|
7af37654a1 | ||
|
|
63520f1678 | ||
|
|
ffefa9d6df | ||
|
|
9adb44845c | ||
|
|
dfe10f6c27 | ||
|
|
9efc9c7005 | ||
|
|
96425dd515 | ||
|
|
8b9798884a | ||
|
|
80bab92c8c | ||
|
|
937b41ddb7 | ||
|
|
cc2ca3c968 | ||
|
|
70b4eb5585 | ||
|
|
86665f08e6 | ||
|
|
c3d99b1f15 | ||
|
|
b9353db507 | ||
|
|
7fdf09839f | ||
|
|
6d9273f418 | ||
|
|
e3bcafb6d9 | ||
|
|
2a96008803 | ||
|
|
8f226bd82f | ||
|
|
c817f0833c | ||
|
|
89fd0a6116 | ||
|
|
c171fb5bc7 | ||
|
|
8542e673a8 | ||
|
|
cbff3e7e33 | ||
|
|
972516d1c4 | ||
|
|
ce7b09a65c | ||
|
|
f09b60aaa2 | ||
|
|
400aa76737 | ||
|
|
b905f3e560 | ||
|
|
c9027381e5 | ||
|
|
50fd6b859f | ||
|
|
06ad174fb9 | ||
|
|
87fa0a4381 | ||
|
|
eff8010d39 | ||
|
|
394eb67c4c | ||
|
|
972a9f825c | ||
|
|
a990e7cc23 | ||
|
|
815236a607 | ||
|
|
a6845ae948 | ||
|
|
1e6836b3c1 | ||
|
|
fc184ecc01 | ||
|
|
81df131217 | ||
|
|
3d17206063 | ||
|
|
4468aa8dd7 | ||
|
|
977d63a2ea | ||
|
|
ae06d8b47e | ||
|
|
1f44e40382 | ||
|
|
d398f488e2 | ||
|
|
89774ab847 | ||
|
|
c731a98a0f | ||
|
|
5f86457848 | ||
|
|
00feb7c1c7 | ||
|
|
d2a778edc0 | ||
|
|
7192c3861e | ||
|
|
e0ccff257d | ||
|
|
c1da784193 | ||
|
|
9d330bed77 | ||
|
|
970c8ca8da | ||
|
|
19156814b8 | ||
|
|
d2b97b3963 | ||
|
|
d4efdb483d | ||
|
|
56c5dfee40 | ||
|
|
82d4a209be | ||
|
|
737611a07e | ||
|
|
eea40bcd49 | ||
|
|
27fd355e53 | ||
|
|
94e94490af | ||
|
|
28ec784ae2 | ||
|
|
a3cc52d6d4 | ||
|
|
d65d632505 | ||
|
|
dd0fefdcb7 | ||
|
|
75ebbf6a57 | ||
|
|
ede3783734 | ||
|
|
d70371a50c | ||
|
|
21e961cb29 | ||
|
|
82f212ab39 | ||
|
|
b55b33ae92 | ||
|
|
8c9a71e6a0 | ||
|
|
646ec1e149 | ||
|
|
8496b289ec | ||
|
|
4dd46ea355 | ||
|
|
505260e227 | ||
|
|
09d9625f32 |
@@ -4,6 +4,9 @@
|
||||
.gitattributes
|
||||
dev
|
||||
docs
|
||||
# test/e2e/.stage holds what an e2e run leaves behind (DKIM keys, sasldb2);
|
||||
# the e2e's own sink image is built from test/e2e/sink as its own context.
|
||||
test
|
||||
bin
|
||||
*.exe
|
||||
README.md
|
||||
|
||||
@@ -89,8 +89,27 @@ jobs:
|
||||
cache-dependency-path: test/e2e/go.sum
|
||||
|
||||
- name: e2e (gates publishing — see docs/development.md)
|
||||
# The e2e builds selfpost:e2e again through compose.override.yml, and
|
||||
# that rebuild is the image pushed below. Without the version here it
|
||||
# was stamped "e2e" (audit 2026-10-12, H1); the suite now checks the
|
||||
# stamp against this variable.
|
||||
env:
|
||||
SELFPOST_E2E_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||
|
||||
- name: Check the version stamp of the image about to be pushed
|
||||
env:
|
||||
WANT: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for bin in panel selfpost-backup; do
|
||||
got=$(docker run --rm --entrypoint "$bin" selfpost:e2e -version)
|
||||
if [ "$got" != "$WANT" ]; then
|
||||
echo "::error::selfpost:e2e $bin -version = '$got', want '$WANT'; refusing to push"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -24,6 +24,9 @@ jobs:
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
test/e2e/go.sum
|
||||
|
||||
- name: Format
|
||||
run: |
|
||||
@@ -37,5 +40,24 @@ jobs:
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# Unused code, dead branches and the usual slips go vet does not look
|
||||
# for. The version is pinned: a new release adds checks, and a check
|
||||
# that appears under a push is a red build nobody asked for. 2026.1 is
|
||||
# the release for Go 1.26. test/e2e is a module of its own, so ./...
|
||||
# skips it; it is checked in its own step below.
|
||||
- name: Staticcheck
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
# The e2e module runs only in release.yml, after a release is published;
|
||||
# a type error there would first show up then. Compile it here without
|
||||
# running it: `go test -run` would still run TestMain, which brings up
|
||||
# the whole stand.
|
||||
- name: Compile the e2e
|
||||
working-directory: test/e2e
|
||||
run: |
|
||||
go vet ./...
|
||||
go test -c -o /dev/null .
|
||||
go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
+1150
File diff suppressed because it is too large
Load Diff
@@ -106,13 +106,23 @@ One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
||||
files, no compose files. Good for clicking through the UI on your machine;
|
||||
outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
|
||||
> **The 2.x line is not published on ghcr.io yet** (the newest image there is
|
||||
> `1.3.0`). Until it is, build the image from this checkout under the name the
|
||||
> commands below and `deploy/docker-compose.yml` use; Docker then takes the
|
||||
> local image and pulls nothing:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.7.0 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
docker run --rm -d --name selfpost-try \
|
||||
-p 127.0.0.1:8080:8080 \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:2.0.0
|
||||
ghcr.io/mixeme/selfpost:2.7.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
+78
-12
@@ -6,7 +6,7 @@
|
||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
|
||||
# ---- build stage -------------------------------------------------------------
|
||||
FROM golang:1.26-bookworm AS build
|
||||
FROM golang:1.26-trixie AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -30,53 +30,118 @@ RUN go vet ./... \
|
||||
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||
|
||||
# ---- runtime stage -----------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
FROM debian:trixie-slim AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Preseed Postfix so its install is non-interactive and yields a working
|
||||
# main.cf. The real relay configuration is generated by the panel.
|
||||
# (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50:
|
||||
# postfix/main_mailer_type and postfix/mailname are the questions the install
|
||||
# asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
|
||||
# configuration".)
|
||||
# openssl is named because postfix-cert-reload.sh runs it, not left to arrive
|
||||
# with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
|
||||
# debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
|
||||
# saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
|
||||
# debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
|
||||
# (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
|
||||
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
|
||||
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
|
||||
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
|
||||
# the panel runs (internal/app/sasl.go); that db-util ships that path on
|
||||
# trixie is not in a source read here - the image build and the e2e sasldb
|
||||
# test say whether it does.
|
||||
# opendkim-tools is not installed: nothing in the image runs its programs
|
||||
# (DKIM keys are made in Go, internal/domain/dkim.go).
|
||||
# systemd-standalone-sysusers is named so that cron-daemon-common, which
|
||||
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
|
||||
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
|
||||
# than with the whole of systemd, the first alternative of its "Depends:
|
||||
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
|
||||
# image, 2026-10-15). Nothing here runs systemd.
|
||||
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postfix \
|
||||
opendkim \
|
||||
opendkim-tools \
|
||||
sasl2-bin \
|
||||
libsasl2-modules \
|
||||
db-util \
|
||||
supervisor \
|
||||
logrotate \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
curl \
|
||||
systemd-standalone-sysusers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Unprivileged user for the panel process.
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
|
||||
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch); with no service
|
||||
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
|
||||
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
|
||||
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
|
||||
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
|
||||
|
||||
# Unprivileged user for the panel process, with ids of its own outside the
|
||||
# system range for the same reason as selfpost's below: its files under
|
||||
# /data show on the host by number, and a number from that range is a
|
||||
# system group of the host's too (useradd gave the panel group 998 in the
|
||||
# trixie image, systemd-network on the test server's host; bookworm's
|
||||
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
|
||||
# back to it by name on every start, so files of the old ids follow.
|
||||
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Shared group bridging the unprivileged services: the panel generates
|
||||
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
|
||||
# sender map, while OpenDKIM and Postfix (different users) must read them.
|
||||
# Membership in this group — plus setgid dirs under /data (set up in
|
||||
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||
RUN groupadd --system selfpost \
|
||||
# Membership in this group - plus setgid dirs under /data (set up in
|
||||
# entrypoint.sh) - lets OpenDKIM read the panel-owned keys and lets Postfix
|
||||
# read the sasldb2/sender map. (The supervisor control socket has a group of
|
||||
# its own, `supervisor`, below.)
|
||||
# The id is fixed, because the host sees it: an operator gives the TLS
|
||||
# private key on the host to this group by number, so that the panel can put
|
||||
# it into a full backup (guide § Taking a full backup). It lies outside the
|
||||
# range the distribution hands out to system groups, 100-999 (adduser 3.152,
|
||||
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
|
||||
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
|
||||
# read in the image, 2026-10-15), so no package can take it, and far above
|
||||
# the 1000 a host starts its users' groups at, so a host is unlikely to have
|
||||
# a group of that number that would read the key too. Until 2.7.0 it was
|
||||
# 998, inside that range: in the trixie image systemd took 998 for
|
||||
# systemd-network and the build failed, and on the test server's host 998
|
||||
# is systemd-network too (seen 2026-10-15).
|
||||
# groupadd(8) refuses a --gid already in use, so an id taken after all would
|
||||
# fail this build rather than share the group.
|
||||
RUN groupadd --system --gid 10998 selfpost \
|
||||
&& usermod -aG selfpost panel \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (and the status page's
|
||||
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
||||
# needed to reach Postfix's showq socket — but the documented deployment runs
|
||||
# needed to reach Postfix's showq socket - but the documented deployment runs
|
||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||
# grants the same access without relying on a setgid escalation the hardening
|
||||
# deliberately forbids. postdrop membership is read-side only: it does not let
|
||||
# deliberately forbids. The same membership is what lets the panel submit its
|
||||
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
|
||||
# to write the maildrop directory (internal/notify/mailer.go). It does not let
|
||||
# the panel bypass any Postfix restriction that a local user does not already
|
||||
# have through the world-executable sendmail.
|
||||
# (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
|
||||
# root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
|
||||
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst:
|
||||
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
|
||||
RUN usermod -aG postdrop panel
|
||||
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
|
||||
# the panel alone - not OpenDKIM's or Postfix's users, which share `selfpost`
|
||||
# for the files under /data.
|
||||
RUN groupadd --system supervisor \
|
||||
&& usermod -aG supervisor panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||
@@ -100,6 +165,7 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
|
||||
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
|
||||
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||
|
||||
+82
-13
@@ -1,17 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||
|
||||
If any managed program exhausts its restart retries and enters FATAL, this
|
||||
listener signals supervisord (its parent) to terminate, so the whole container
|
||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||
If a managed program of the mail path exhausts its restart retries and
|
||||
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
|
||||
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
|
||||
terminate, so the whole container exits and Docker's `restart` policy starts it
|
||||
again, rather than lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||
FATAL alone did not cover the second: a program reaches FATAL only through
|
||||
exits before its startsecs, and one that crashed after it was restarted for
|
||||
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
|
||||
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
|
||||
it would ignore) stays on Server > Health, and mail keeps flowing
|
||||
(assumptions C-V5).
|
||||
|
||||
Communication with supervisord uses the event listener protocol over stdin and
|
||||
stdout, so stdout must carry only protocol tokens.
|
||||
|
||||
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
|
||||
listener writes READY\\n, reads one header line of key:value tokens whose len
|
||||
is the payload's length and eventname the event's type, reads the payload, and
|
||||
answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
|
||||
"processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
|
||||
adds "expected:0" for an exit code the program's exitcodes do not list, "1"
|
||||
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
|
||||
supervisord starts the listener as its own child, and on SIGTERM it shuts down
|
||||
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
|
||||
|
||||
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
|
||||
for the test (build/crashexit_test.go).
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Programs whose failure does not take the container down
|
||||
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
|
||||
# runs on demand - a reload it could not start is reported to whoever asked for
|
||||
# it - syslog-relay carries OpenDKIM's log lines, not mail, and cert-reload
|
||||
# looks for a renewed certificate once a day (a crash of it leaves the one
|
||||
# Postfix serves).
|
||||
SPARED = {"logrotate", "postfix-reload", "syslog-relay", "cert-reload"}
|
||||
|
||||
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
|
||||
# program that does not stay up. A program on the mail path restarts in
|
||||
# seconds; five times in ten minutes is no passing fault.
|
||||
CRASH_LIMIT = 5
|
||||
CRASH_WINDOW = 600
|
||||
|
||||
|
||||
def write_stdout(s):
|
||||
@@ -19,7 +54,20 @@ def write_stdout(s):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def say(s):
|
||||
sys.stderr.write("crashexit: " + s + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def shut_down():
|
||||
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
|
||||
say("would shut down")
|
||||
return
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def main():
|
||||
crashes = {} # program -> times of its unexpected exits in the window
|
||||
while True:
|
||||
# Tell supervisord we are ready for the next event.
|
||||
write_stdout("READY\n")
|
||||
@@ -29,16 +77,37 @@ def main():
|
||||
return
|
||||
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||
payload_len = int(headers.get("len", 0))
|
||||
if payload_len:
|
||||
sys.stdin.read(payload_len)
|
||||
|
||||
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||
# program can no longer be restarted. Take the container down.
|
||||
sys.stderr.write(
|
||||
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||
payload = sys.stdin.read(payload_len) if payload_len else ""
|
||||
tokens = dict(
|
||||
pair.split(":", 1) for pair in payload.split() if ":" in pair
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
name = tokens.get("processname", "")
|
||||
event = headers.get("eventname", "PROCESS_STATE_FATAL")
|
||||
|
||||
if event == "PROCESS_STATE_EXITED":
|
||||
if name not in SPARED and tokens.get("expected") == "0":
|
||||
now = time.monotonic()
|
||||
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
|
||||
times.append(now)
|
||||
crashes[name] = times
|
||||
if len(times) >= CRASH_LIMIT:
|
||||
say(
|
||||
"%s exited unexpectedly %d times in %d seconds; shutting down container"
|
||||
% (name, len(times), CRASH_WINDOW)
|
||||
)
|
||||
shut_down()
|
||||
crashes[name] = []
|
||||
elif name in SPARED:
|
||||
say(
|
||||
"%s entered FATAL; it is not on the mail path, the container stays up"
|
||||
% name
|
||||
)
|
||||
else:
|
||||
say(
|
||||
"%s entered FATAL; shutting down container"
|
||||
% (name or "a managed process")
|
||||
)
|
||||
shut_down()
|
||||
|
||||
write_stdout("RESULT 2\nOK")
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package build holds the image's scripts; this file tests crashexit.py, the
|
||||
// supervisor event listener that takes the container down.
|
||||
package build
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// event is one event as supervisord writes it to a listener's stdin
|
||||
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
|
||||
// payload's length, then the payload).
|
||||
func event(name, payload string) string {
|
||||
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
|
||||
}
|
||||
|
||||
// runListener feeds events to crashexit.py in dry run and returns what it
|
||||
// wrote to stderr.
|
||||
func runListener(t *testing.T, events ...string) string {
|
||||
t.Helper()
|
||||
// python3 on Linux; on Windows "python3" may be the Store's stub, which
|
||||
// runs nothing.
|
||||
names := []string{"python3", "python"}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = []string{"python", "python3"}
|
||||
}
|
||||
python := ""
|
||||
for _, n := range names {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
python = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if python == "" {
|
||||
t.Skip("no python")
|
||||
}
|
||||
cmd := exec.Command(python, "crashexit.py")
|
||||
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
|
||||
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
|
||||
}
|
||||
// Python on Windows writes its text stdout with \r\n.
|
||||
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
|
||||
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// A program on the mail path that keeps crashing after its startsecs - never
|
||||
// FATAL - takes the container down at the fifth unexpected exit (audit
|
||||
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
|
||||
func TestCrashexitCountsCrashes(t *testing.T) {
|
||||
exit := func(name, expected string) string {
|
||||
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
|
||||
}
|
||||
var four []string
|
||||
for i := 0; i < 4; i++ {
|
||||
four = append(four, exit("panel", "0"))
|
||||
}
|
||||
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("four crashes shut down:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("five crashes did not shut down:\n%s", got)
|
||||
}
|
||||
var calm []string
|
||||
for i := 0; i < 6; i++ {
|
||||
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
|
||||
}
|
||||
calm = append(calm, exit("opendkim", "0"), exit("opendkim", "0"), exit("opendkim", "0"))
|
||||
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FATAL of a mail-path program shuts down at once; of a spared one, not.
|
||||
func TestCrashexitFatal(t *testing.T) {
|
||||
fatal := func(name string) string {
|
||||
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
|
||||
}
|
||||
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("postfix FATAL:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, fatal("logrotate"), fatal("syslog-relay")); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("a spared program's FATAL shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
+115
-17
@@ -62,21 +62,54 @@ esac
|
||||
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
|
||||
# mode 0o700".)
|
||||
chown panel:panel /data
|
||||
chmod 755 /data
|
||||
|
||||
# What follows runs as root over a tree the panel user writes: /data is the
|
||||
# panel's, and so is /data/postfix. A symbolic link the panel put where a
|
||||
# directory or a file of its own is expected would lead root's mkdir, ": >",
|
||||
# chmod and chgrp to whatever it names (chmod follows a link; so do a
|
||||
# redirect and mkdir -p; chown -R and find do not descend into one given as
|
||||
# their operand). None of these paths is ever a link in a working
|
||||
# installation, so a link there is removed and the path made anew below.
|
||||
# (Security review of base-trixie, 2026-10-15: no escalation found, as the
|
||||
# TLS key copy and the read-only mounts stand, but nothing for root to
|
||||
# follow either.)
|
||||
drop_links() {
|
||||
for p in "$@"; do
|
||||
if [ -L "$p" ]; then
|
||||
echo "entrypoint: removing the symbolic link $p" >&2
|
||||
rm -f "$p"
|
||||
fi
|
||||
done
|
||||
}
|
||||
drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
|
||||
/data/opendkim/keys /data/opendkim/KeyTable /data/opendkim/SigningTable \
|
||||
/data/sasl/sasldb2 /data/postfix/queue \
|
||||
/data/postfix/sender_login_maps /data/postfix/relay_domains \
|
||||
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
|
||||
/data/log/mail.log
|
||||
# Restored backups or previously-created state may contain panel-owned files
|
||||
# under /data; make sure they stay writable without disturbing anything that a
|
||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||
# normalised on its own below.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||
# normalised on its own below, as are the trees shared through group selfpost.
|
||||
# A file's owner is a number: an entry whose user or group is not the panel's
|
||||
# by that number - unpacked by root, or written under another image whose
|
||||
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
|
||||
# handed over whole.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \
|
||||
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
|
||||
|
||||
# DKIM key tree. The panel (user `panel`) generates keys and writes
|
||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||
# just restored from a backup:
|
||||
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||
# inherits the shared group OpenDKIM traverses;
|
||||
# inherits the shared group OpenDKIM traverses (inode(7), "The
|
||||
# set-group-ID bit": files created in such a directory take its group);
|
||||
# - private keys and tables group-readable (0640);
|
||||
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||
# daemon comes up cleanly with no domains yet.
|
||||
@@ -98,6 +131,10 @@ mkdir -p /data/sasl
|
||||
chown -R panel:selfpost /data/sasl
|
||||
chmod 2750 /data/sasl
|
||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||
# The copy a password change is made on (internal/app, replaceSASLDB), left
|
||||
# by a panel killed mid-change: a second copy of every password, which the
|
||||
# next change would remove anyway.
|
||||
rm -f /data/sasl/sasldb2.new
|
||||
|
||||
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||
@@ -107,25 +144,46 @@ mkdir -p /data/postfix/queue
|
||||
# Empty files keep postfix check happy if the flag is on before any domain exists.
|
||||
for f in relay_domains transport relay_recipients tls_policy; do
|
||||
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
||||
chown panel:selfpost "/data/postfix/$f"
|
||||
chmod 0640 "/data/postfix/$f"
|
||||
done
|
||||
# Every map here is the panel's to write and Postfix's to read - the sender
|
||||
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
|
||||
# handed to the panel, group selfpost, whatever its name. A restored archive's
|
||||
# files arrive owned by whoever unpacked them (a backup records no owner, and
|
||||
# tar run as root makes them root's), and the first start after a restore
|
||||
# reads every one of them to compare it with the archive's record: one left
|
||||
# to root alone is a restore refused on every start (review of audit stage 3,
|
||||
# H1 - the DMARC maps were not among the names listed here).
|
||||
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
|
||||
chown panel:selfpost /data/postfix
|
||||
chmod 2750 /data/postfix
|
||||
chown panel:selfpost /data/postfix/sender_login_maps
|
||||
chmod 0640 /data/postfix/sender_login_maps
|
||||
|
||||
# Filter lists for the external spam filter (written by the panel when
|
||||
# INBOUND_RELAY_ENABLE=true, read by the antispam sidecar, which mounts this
|
||||
# directory). The sidecar runs under a user of its own in another container,
|
||||
# so the directory and its files are readable by anyone: they hold sender
|
||||
# addresses and networks, no secrets.
|
||||
mkdir -p /data/antispam
|
||||
chown -R panel:selfpost /data/antispam
|
||||
chmod 0755 /data/antispam
|
||||
|
||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||
# `postfix`; the panel reads it for the log-tailer and the System log page. It
|
||||
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||
# resolve a "queued" send-log row survive a container recreate.
|
||||
#
|
||||
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# postlogd creates a missing log itself, by default at 0600, which the
|
||||
# unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
|
||||
# maillog_file_permissions); so create it here (and re-normalise an existing
|
||||
# one, plus whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# directory keeps the shared group on anything created inside it later, and
|
||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||
# rotate a log whose directory is writable by a non-root group.
|
||||
# Postfix 3.10.13: src/util/logwriter.c:99, logwriter_open_or_die(), creates the
|
||||
# file with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (proto/postconf.proto:18500, default 0600; Postfix 3.9 and later).
|
||||
# logrotate 3.22.0, logrotate.c:1406-1408, findNeedRotating(): "skipping ...
|
||||
# because parent directory has insecure permissions" when the directory's
|
||||
# group is not root and has write permission.
|
||||
mkdir -p /data/log
|
||||
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||
chown -R postfix:selfpost /data/log
|
||||
@@ -133,8 +191,8 @@ chmod 2750 /data/log
|
||||
find /data/log -type f -exec chmod 0640 {} +
|
||||
|
||||
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
||||
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
|
||||
# probe them at start-up. The sockets are created by the opendkim and panel
|
||||
# the milter sockets — OpenDKIM's and the panel's — not just probe them at
|
||||
# start-up. The sockets are created by the opendkim and panel
|
||||
# users respectively, so bridge them to `postfix` through the shared `selfpost`
|
||||
# group: group-owned + setgid dirs mean each socket created inside inherits
|
||||
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
|
||||
@@ -144,6 +202,26 @@ mkdir -p /run/opendkim /run/selfpost
|
||||
chown opendkim:selfpost /run/opendkim
|
||||
chown panel:selfpost /run/selfpost
|
||||
chmod 2750 /run/opendkim /run/selfpost
|
||||
# A container that was killed leaves its sockets behind, and a file nobody
|
||||
# listens on would satisfy postfix-wrapper.sh's wait: Postfix would start
|
||||
# before the sender check does and defer mail until it is there. The check's
|
||||
# alive mark goes too: it tells the downtime of the panel restarting inside a
|
||||
# running container, and a restart of the whole container is not the check's
|
||||
# downtime (guide § Health) - a mark left from before would count the whole
|
||||
# outage when supervisord cannot say since when Postfix runs.
|
||||
# (test(1), -S: true for any socket file, whether or not anything listens.)
|
||||
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
|
||||
|
||||
# The container's /dev/log: a link to the socket the syslog-relay program (the
|
||||
# panel's binary, as `panel`) listens on, so what OpenDKIM logs through
|
||||
# syslog(3) reaches the container's log (build/supervisord.conf; audit
|
||||
# 2026-10-12, L30). The directory is the relay's and reachable by all: the
|
||||
# socket is the container's local log, as /dev/log is on a host.
|
||||
mkdir -p /run/syslog
|
||||
chown panel:panel /run/syslog
|
||||
chmod 0755 /run/syslog
|
||||
rm -f /run/syslog/log
|
||||
ln -sfn /run/syslog/log /dev/log
|
||||
|
||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||
@@ -151,11 +229,12 @@ chmod 2750 /run/opendkim /run/selfpost
|
||||
# start the same way the /data normalisation above is.
|
||||
/usr/local/bin/postfix-config.sh
|
||||
|
||||
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||
if [ ! -d /data/postfix/queue/active ]; then
|
||||
postfix set-permissions
|
||||
fi
|
||||
# The queue tree, on a first start or after a restore, is already built here:
|
||||
# postfix-config.sh ends with "postfix check", whose check-fatal runs
|
||||
# "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
|
||||
# conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
|
||||
# A "postfix set-permissions" for a missing active/ stood here and never ran
|
||||
# (assumptions C-P34).
|
||||
chown -R postfix:postfix /data/postfix/queue
|
||||
# The recursive chown above would leave public/ and maildrop/ in group postfix.
|
||||
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
|
||||
@@ -167,6 +246,25 @@ chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
|
||||
# It would also leave the queue directory itself and pid/ owned by postfix.
|
||||
# Postfix keeps those two owned by root (master, which runs as root, is the only
|
||||
# writer) and logs "warning: not owned by root" for each at every start.
|
||||
# (Postfix 3.10.13, conf/postfix-files:51-68: $queue_directory root 755, maildrop
|
||||
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
|
||||
# directories $mail_owner 700; conf/postfix-script:354-356, "check-warn": "not
|
||||
# owned by root".)
|
||||
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||
|
||||
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the
|
||||
# wrapper's wait for the milter sockets, so its exit at the end of the wait is
|
||||
# one supervisord counts as too quick. A value that is not a number stops the
|
||||
# start here.
|
||||
wait_timeout="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
case "$wait_timeout" in
|
||||
'' | *[!0-9]*)
|
||||
echo "entrypoint: MILTER_WAIT_TIMEOUT must be a whole number of seconds, got '$wait_timeout'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# expr, not $((...)): dash's arithmetic reads "010" as octal and refuses
|
||||
# "08"; expr takes decimal (review of stage 2c, F11).
|
||||
export POSTFIX_STARTSECS=$(expr "$wait_timeout" + 10)
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
|
||||
+44
-10
@@ -7,18 +7,49 @@
|
||||
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
||||
# live container — so logrotate must be the one to create it.
|
||||
# live container (date not recorded, Postfix 3.7.11), and so in the source:
|
||||
# Postfix 3.10.13, src/util/logwriter.c:99, logwriter_open_or_die(), creates it
|
||||
# with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (Postfix 3.9 and later). build/postfix-config.sh now sets that to
|
||||
# 0640, so a file postlogd makes is readable too; logrotate still creates it,
|
||||
# so that the new log is there, with its mode and owner, at once.
|
||||
# The reload: Postfix 3.10.13, conf/postfix-script:535-566, "logrotate" (mv,
|
||||
# then kill -HUP master); master_conf.c:92, master_config() -> :147,
|
||||
# master_restart_service() -> master_status_cleanup(), on which postlogd exits
|
||||
# (src/master/dgram_server.c:241, dgram_server_abort(), :640 on
|
||||
# MASTER_STATUS_FD), and the next postlogd opens the path anew
|
||||
# (src/postlogd/postlogd.c:236, logwriter_open_or_die()).
|
||||
#
|
||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
||||
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||
# (tracked in its state file), so it is safe to invoke this more often than
|
||||
# daily — polling merely bounds how late a legitimate rotation runs. The state
|
||||
# file is beside the log, under /data, not at the default
|
||||
# /var/lib/logrotate/status (logrotate 3.22.0-1: debian/rules:11,
|
||||
# --with-state-file-path) in the container's own filesystem: a recreated
|
||||
# container started without it, logrotate.c:456, newState(), records a log it
|
||||
# has no state for as rotated today, and so a host that recreates the container
|
||||
# daily - an .env change, an image bump - never rotated mail.log at all (audit
|
||||
# 2026-10-12, M19). (logrotate 3.22.0, logrotate.8.in:74: "-s, --state
|
||||
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
|
||||
# root.
|
||||
set -eu
|
||||
|
||||
CONFIG=/etc/logrotate.d/mail
|
||||
STATE=/data/log/.logrotate.status
|
||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||
|
||||
# logrotate refuses configs writable by group or others and exits 0 while
|
||||
# ignoring them — fail here so supervisord reports the fault.
|
||||
# logrotate refuses configs writable by group or others and goes on without
|
||||
# them — fail here so supervisord reports the fault. (logrotate 3.22.0,
|
||||
# config.c:1099, readConfigFile(): as root, mode & 0022 gives (:1160-1165)
|
||||
# "Ignoring ... because it is writable by group or others." and returns 1; a
|
||||
# config not owned by root is ignored the same way (:1168-1173). Since 3.22.0
|
||||
# that makes logrotate's exit status 1 (logrotate.c:3278-3279 and :3301;
|
||||
# ChangeLog.md, 3.22.0: "in case a configuration file was skipped due to
|
||||
# unsafe permissions the exit status after rotattion will be `1` (#508)");
|
||||
# 3.21.0 returned 0 there and exited 0. So the output is looked at whatever
|
||||
# the status. "Potentially dangerous mode" is only a warning there
|
||||
# (:1154-1157), for any mode & 07533 other than 0400 - this loop chooses to
|
||||
# treat it as fatal too.)
|
||||
logrotate_config_ok() {
|
||||
mode=$(stat -c '%a' "$CONFIG")
|
||||
mode=${mode#0}
|
||||
@@ -39,16 +70,19 @@ if ! logrotate_config_ok; then
|
||||
fi
|
||||
|
||||
run_logrotate() {
|
||||
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||
echo "$out" >&2
|
||||
return 1
|
||||
}
|
||||
status=0
|
||||
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
|
||||
case "$out" in
|
||||
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||
echo "$out" >&2
|
||||
logrotate_config_fatal
|
||||
echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
||||
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
|
||||
# (logrotate 3.22.0, logrotate.8:402: "create mode owner group" makes the new log
|
||||
# right after the rotation; "delaycompress" leaves the previous log, .1,
|
||||
# uncompressed until the next cycle.)
|
||||
/data/log/mail.log {
|
||||
daily
|
||||
rotate 14
|
||||
|
||||
+34
-4
@@ -3,27 +3,57 @@
|
||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
||||
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||
# supervisord to send this process SIGUSR1 to reload the tables.
|
||||
# supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM
|
||||
# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is
|
||||
# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(),
|
||||
# opens SigningTable and KeyTable again with dkimf_db_open().)
|
||||
#
|
||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
||||
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||
# signs nothing until the first domain is added.
|
||||
# signs nothing until the first domain is added. (opendkim/opendkim-db.c,
|
||||
# dkimf_db_open(): a file or refile table is read line by line; an empty file
|
||||
# is an empty table, not an error.)
|
||||
|
||||
Syslog no
|
||||
# Logged through syslog(3) to /dev/log, which the syslog-relay program passes
|
||||
# to the container's log (build/supervisord.conf; audit 2026-10-12, L30). The
|
||||
# errors are what is wanted - a key that cannot be read, a table that cannot
|
||||
# be opened; a line per signed message (SyslogSuccess) is not.
|
||||
Syslog yes
|
||||
UMask 007
|
||||
Mode s
|
||||
|
||||
# relaxed/simple: the header fields relaxed, so a relay that refolds or
|
||||
# respaces one does not break the signature; the body simple, as signed
|
||||
# (opendkim.conf.5.in, Canonicalization: header/body, the default
|
||||
# simple/simple; RFC 6376 § 3.4). OversignHeaders From lists From: in h= once
|
||||
# more than it is present, so a From: added on the way fails the signature
|
||||
# instead of being shown above the signed one (opendkim.conf.5.in,
|
||||
# OversignHeaders; RFC 6376 § 8.15) - the replay 2.4.0's sender check is
|
||||
# about.
|
||||
Canonicalization relaxed/simple
|
||||
OversignHeaders From
|
||||
|
||||
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
||||
# local-part for a domain; KeyTable maps each domain to its key and selector.
|
||||
# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are
|
||||
# wildcard patterns matched against the From: address, in file order.)
|
||||
KeyTable /data/opendkim/KeyTable
|
||||
SigningTable refile:/data/opendkim/SigningTable
|
||||
|
||||
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
||||
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
|
||||
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
|
||||
# key-safety check, so the check is disabled here by design.
|
||||
# key-safety check, so the check is disabled here by design. (opendkim/
|
||||
# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the
|
||||
# executing uid, and a group-readable one must be in a group of no other user -
|
||||
# `panel` owns these and `selfpost` has three members.)
|
||||
RequireSafeKeys no
|
||||
|
||||
# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in,
|
||||
# UMask: it applies to the socket); its group is selfpost by the setgid
|
||||
# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's
|
||||
# groups (opendkim.conf.5.in, UserID); with Background no and -f
|
||||
# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself.
|
||||
Socket local:/run/opendkim/opendkim.sock
|
||||
PidFile /run/opendkim/opendkim.pid
|
||||
UserID opendkim
|
||||
|
||||
@@ -1,25 +1,94 @@
|
||||
#!/bin/sh
|
||||
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
||||
# few months; Postfix only re-reads them on reload. A simple daily reload is
|
||||
# more than enough (a day of staleness is harmless) and far simpler than an
|
||||
# inotify watcher — the spec explicitly prefers this.
|
||||
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
|
||||
# audit 2026-10-12, H2).
|
||||
#
|
||||
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||
# still bringing Postfix up then) and none until at least one interval has
|
||||
# passed. A reload is harmless when nothing changed.
|
||||
set -eu
|
||||
# Postfix does not read the mounted files: postfix-config.sh copies them at
|
||||
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
|
||||
# root with mode 0600, which a read-only bind mount owned by the host's user
|
||||
# cannot give. A renewal of the mounted files therefore reaches Postfix only
|
||||
# through a new copy. Every interval this compares the mounted files with the
|
||||
# copy; when they differ it copies both beside their targets, checks that the
|
||||
# copies - not the mounted files, which a renewer may be rewriting - are a
|
||||
# certificate and the key that belongs to it, renames both over the targets
|
||||
# and reloads Postfix. When nothing changed it does nothing.
|
||||
# (Postfix 3.10.13: smtpd loads the certificate in pre_jail_init(), once per
|
||||
# process, src/smtpd/smtpd.c:6442; "postfix reload" is SIGHUP to master,
|
||||
# conf/postfix-script:277, after which the old smtpd processes exit on the closed
|
||||
# status pipe, src/master/master_conf.c, master_config() ->
|
||||
# master_restart_service(), and new ones load the files again.)
|
||||
#
|
||||
# Runs under supervisord as root. It sleeps first: at container start the
|
||||
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure
|
||||
# does not trip the crashexit listener; it is logged and tried again.
|
||||
set -u
|
||||
umask 077 # the key's copy is never readable by others, not even for an instant
|
||||
|
||||
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
||||
SRC_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
SRC_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
DIR=/etc/postfix/tls-internal
|
||||
CERT="$DIR/fullchain.pem"
|
||||
KEY="$DIR/privkey.pem"
|
||||
PENDING=0 # a copy was renewed and Postfix has not been reloaded since
|
||||
|
||||
reload() {
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded ($1)"
|
||||
PENDING=0
|
||||
else
|
||||
echo "cert-reload: postfix reload failed, will try again after ${INTERVAL}s" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# same_pair says whether a certificate file and a private key file belong
|
||||
# together, by comparing the public key each one prints. openssl-x509(1) and
|
||||
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
|
||||
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
|
||||
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
|
||||
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
|
||||
same_pair() {
|
||||
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
|
||||
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
|
||||
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if [ "$PENDING" = 1 ]; then
|
||||
reload "renewed certificate, earlier reload had failed"
|
||||
fi
|
||||
if [ "$(postconf -h smtpd_tls_cert_file 2>/dev/null)" != "$CERT" ]; then
|
||||
# Postfix reads the mounted files themselves (they were missing at
|
||||
# start, so no copy was made): a reload is what picks up a change.
|
||||
reload "periodic TLS refresh"
|
||||
return
|
||||
fi
|
||||
if [ ! -r "$SRC_CERT" ] || [ ! -r "$SRC_KEY" ]; then
|
||||
echo "cert-reload: cannot read ${SRC_CERT} or ${SRC_KEY}; Postfix keeps the certificate it has" >&2
|
||||
return
|
||||
fi
|
||||
if cmp -s "$SRC_CERT" "$CERT" && cmp -s "$SRC_KEY" "$KEY"; then
|
||||
return
|
||||
fi
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
if ! cp "$SRC_CERT" "$CERT.new" || ! cp "$SRC_KEY" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: copying the renewed certificate failed, will try again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
if ! same_pair "$CERT.new" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: the mounted certificate and key differ from Postfix's copy but do not belong together; will look again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
chown root:root "$CERT.new" "$KEY.new"
|
||||
chmod 0644 "$CERT.new"
|
||||
chmod 0600 "$KEY.new"
|
||||
mv -f "$KEY.new" "$KEY" && mv -f "$CERT.new" "$CERT"
|
||||
PENDING=1
|
||||
reload "renewed certificate"
|
||||
}
|
||||
|
||||
while true; do
|
||||
sleep "${INTERVAL}"
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded (periodic TLS refresh)"
|
||||
else
|
||||
# Never exit non-zero: a transient reload failure must not trip the
|
||||
# crashexit listener and take the container down. Log and retry next cycle.
|
||||
echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2
|
||||
fi
|
||||
refresh
|
||||
done
|
||||
|
||||
+400
-28
@@ -23,6 +23,20 @@ set -eu
|
||||
# splits a restriction list on commas as well as on whitespace, so every value
|
||||
# passed here is written with commas only; a value with whitespace is a bug in
|
||||
# this script and stops the container.
|
||||
#
|
||||
# Postfix 3.10.13, man/man5/master.5:215-226, "-o name=value (short form)":
|
||||
# "The "short form" does not support whitespace around the "=" or in
|
||||
# parameter values" (the long form "-o { name = value }" would keep it; this
|
||||
# script does not write it). src/postconf/postconf_edit.c:415,
|
||||
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
|
||||
# src/smtpd/smtpd_check.c:664, smtpd_check_parse(), and src/milter/milter.c:646,
|
||||
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
|
||||
#
|
||||
# master.5:41-42, SYNTAX: a line that starts with whitespace continues a
|
||||
# logical line, so the "-o" line awk inserts below the header belongs to the
|
||||
# service. A later postconf -F/-P re-parses a matched entry with its
|
||||
# continuation lines (postconf_edit.c:134, pcf_gobble_cf_line()) and prints
|
||||
# it whole again, keeping these options.
|
||||
master_cf_set_smtp_inet_o() {
|
||||
param="$1"
|
||||
value="$2"
|
||||
@@ -62,16 +76,27 @@ master_cf_set_smtp_inet_o() {
|
||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||
# fails. Fall back to the container hostname only outside a real deployment.
|
||||
# Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:339, xsasl_cyrus_server_create():
|
||||
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
|
||||
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
|
||||
# looked up under serverFQDN.
|
||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
||||
# relay requires TLS on 465; if these files are absent the master still starts
|
||||
# but TLS handshakes on 465 fail until they appear.
|
||||
# but TLS handshakes on 465 fail until they appear. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
|
||||
# made, and smtpd_proto() then drops each wrapper-mode connection (:5846,
|
||||
# "Wrapper-mode request dropped ... TLS context initialization failed"); every
|
||||
# new smtpd process loads the files again in pre_jail_init().
|
||||
#
|
||||
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||
# copy into a writable internal dir and normalise ownership before postconf
|
||||
# and `postfix check`.
|
||||
# Postfix's TLS_README advises a private key owned by root and readable by no
|
||||
# other user; Postfix does not enforce it (smtpd loads the key as root in
|
||||
# pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
|
||||
# give that mode - it is :ro and owned by the host's user, often readable by
|
||||
# group 10998 for the panel's backup. So the pair is copied into a directory of
|
||||
# Postfix's own, the key root:root 0600, before postconf and `postfix check`
|
||||
# (assumptions D-arch:154).
|
||||
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||
@@ -93,13 +118,78 @@ fi
|
||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
|
||||
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||
# The settings below reach main.cf and master.cf as numbers or time values.
|
||||
# A value that is neither, or one under the parameter's minimum, passes
|
||||
# `postfix check` and kills the daemon that reads it - every smtpd at its
|
||||
# first connection, or anvil and with it the rate limit (audit 2026-10-12,
|
||||
# L31; review of stage 5) - so the start stops here, saying which and why.
|
||||
# Counts and sizes are whole numbers from min up to 2147483647, an int's top
|
||||
# (Postfix 3.10.13 reads the counts with get_mail_conf_int*, src/global/
|
||||
# mail_conf_int.c, check_mail_conf_int(): an int, held to the parameter's own minimum;
|
||||
# message_size_limit is a long, src/global/mail_params.h:2014, and is held to
|
||||
# the int's top here all the same). A time value
|
||||
# is a whole number from 1 with an optional unit, s m h d or w, at most
|
||||
# 2147483647 seconds
|
||||
# (proto/postconf.proto:266-268: "Specify a non-zero time value (an integral value
|
||||
# plus an optional one-letter suffix ...). Time units: s (seconds), m
|
||||
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
|
||||
# 1h, src/global/conv_time.c - so it is refused here).
|
||||
need_number() {
|
||||
case "$2" in
|
||||
'' | *[!0-9]*)
|
||||
echo "postfix-config: $1 must be a whole number, got '$2'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#2}" -gt 10 ] || [ "$2" -lt "$3" ] || [ "$2" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 must be from $3 to 2147483647, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
need_time() {
|
||||
if ! printf '%s' "$2" | grep -Eq '^[1-9][0-9]{0,8}[smhdw]?$'; then
|
||||
echo "postfix-config: $1 must be a time value - a whole number from 1 with an optional s, m, h, d or w - got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
# conv_time multiplies by the unit and refuses what passes an int.
|
||||
n=${2%[smhdw]}
|
||||
case "$2" in
|
||||
*w) n=$((n * 604800)) ;;
|
||||
*d) n=$((n * 86400)) ;;
|
||||
*h) n=$((n * 3600)) ;;
|
||||
*m) n=$((n * 60)) ;;
|
||||
esac
|
||||
if [ "$n" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 is more than 2147483647 seconds, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
# A rate of 0 is no limit, a size of 0 none (proto/postconf.proto,
|
||||
# smtpd_client_message_rate_limit, message_size_limit); the window is
|
||||
# anvil_rate_time_unit, at least 1 second (src/anvil/anvil.c, main(): its
|
||||
# time table's minimum 1).
|
||||
need_number RATE_LIMIT_MESSAGES_PER_IP "$RATE_MSGS" 0
|
||||
need_number RATE_LIMIT_WINDOW_SECONDS "$RATE_WINDOW" 1
|
||||
need_number INBOUND_RATE_LIMIT_MESSAGES_PER_IP "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number INBOUND_MESSAGE_SIZE_LIMIT "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" 0
|
||||
need_number DMARC_RATE_LIMIT_MESSAGES_PER_IP "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number DMARC_MESSAGE_SIZE_LIMIT "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" 0
|
||||
need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
|
||||
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
|
||||
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check
|
||||
# (strict) and its journal-milter (monitoring, fail-open). Fixed container
|
||||
# paths, matched by postfix-wrapper.sh.
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
# The panel's sender check (the From: line against the login, strict).
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
|
||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
||||
# unprivileged panel can rewrite it and just ask for a reload.
|
||||
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.10.13,
|
||||
# html/DATABASE_README.html:458-464, "texthash": no postmap(1), and the file is read
|
||||
# once by each process - a reload makes the new processes read it again.
|
||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||
# Transit mail queue under /data so deferred/active messages survive container
|
||||
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||
@@ -117,6 +207,9 @@ SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
||||
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
# The panel's inbound journal milter (monitoring, fail-open), on port 25 only
|
||||
# and only while the inbound relay is on. Matched by postfix-wrapper.sh.
|
||||
INBOUND_JOURNAL_SOCK="${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
||||
@@ -141,30 +234,63 @@ DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_rela
|
||||
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||
|
||||
# /etc/postfix outlives a restart of the container, so a cleanup limit set for
|
||||
# port 25 before is taken back here and set again below when it still applies.
|
||||
postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
|
||||
|
||||
# --- main.cf -----------------------------------------------------------------
|
||||
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||
# inet_protocols=all in a bridge network without IPv6: Postfix 3.10.13,
|
||||
# src/util/inet_proto.c:200, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
|
||||
# succeeds, and src/smtp/smtp_connect.c:1102-1142, smtp_connect_inet(), goes on
|
||||
# to the next address when a connection fails - an AAAA that cannot be reached
|
||||
# costs one failed attempt, then IPv4. Postfix
|
||||
# 3.10.13, src/global/mail_params.h:4406, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
|
||||
# src/global/maillog_client.c:232, maillog_client_init(): fatal "does not match any
|
||||
# prefix", in every Postfix program that logs, postfix(1) itself included.
|
||||
#
|
||||
# maillog_file_permissions=0640: the log is the panel's to read, through
|
||||
# group selfpost (the setgid /data/log, build/entrypoint.sh). entrypoint.sh
|
||||
# and logrotate's "create" make it 0640 themselves; this is for a file that
|
||||
# Postfix makes - one removed while the container runs - which would
|
||||
# otherwise be 0600 and unreadable to the panel. (Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4418, DEF_MAILLOG_FILE_PERMS "0600";
|
||||
# src/global/mail_params.c:997, set_logwriter_create_perms(); src/util/
|
||||
# logwriter.c:143-148: "644", "640" or "600", leading zeros ignored, anything
|
||||
# else a warning and 0600; :99, logwriter_open_or_die() creates with it;
|
||||
# Postfix 3.9 and later, proto/postconf.proto:18500-18509.)
|
||||
postconf -e \
|
||||
"myhostname=${HOSTNAME_VALUE}" \
|
||||
"maillog_file=${MAIL_LOG_PATH}" \
|
||||
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||
"maillog_file_permissions=0640" \
|
||||
"mydestination=" \
|
||||
"relayhost=" \
|
||||
"inet_interfaces=all" \
|
||||
"inet_protocols=all"
|
||||
|
||||
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||
# postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
|
||||
# html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
|
||||
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
|
||||
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||
# image cannot lose the service.
|
||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||
|
||||
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||
# these so a misfiled recipient never gets delivered locally.
|
||||
# these so a misfiled recipient never gets delivered locally. An empty
|
||||
# mydestination is not enough: an address literal of an interface the
|
||||
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.10.13,
|
||||
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
|
||||
# itself is replaced by a refusal, as Postfix's null-client example does
|
||||
# (README_FILES/STANDARD_CONFIGURATION_README:209, "local_transport = error:local
|
||||
# mail delivery is disabled"; assumptions C-P8).
|
||||
postconf -e \
|
||||
"local_recipient_maps=" \
|
||||
"alias_maps=" \
|
||||
"alias_database="
|
||||
"alias_database=" \
|
||||
"local_transport=error:local mail delivery is disabled"
|
||||
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
||||
postconf -e \
|
||||
@@ -172,7 +298,10 @@ postconf -e \
|
||||
"smtp_tls_note_starttls_offer=yes"
|
||||
|
||||
# TLS server material shared by every inbound service. auth_only guarantees
|
||||
# credentials are never accepted before TLS is up on any port.
|
||||
# credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_sasl_proto.c:188, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
|
||||
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c:6546-6547,
|
||||
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
|
||||
postconf -e \
|
||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||
@@ -180,11 +309,30 @@ postconf -e \
|
||||
"smtpd_tls_auth_only=yes" \
|
||||
"smtpd_tls_loglevel=1"
|
||||
|
||||
# TLS 1.2 at least, opportunistic or required - and the client's too where an
|
||||
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
|
||||
# the security review of stage 5) (audit 2026-10-12, L31; Postfix
|
||||
# 3.10.13, proto/postconf.proto:13240-13250, smtpd_tls_protocols: since 3.6 the
|
||||
# default is ">=TLSv1", and a lower bound is written ">=version"). A sender that
|
||||
# offers less on port 25 sends in the clear, as one without TLS does. VRFY
|
||||
# answers no question about who is accepted on port 25 (relay_recipient_maps),
|
||||
# and the banner names no distribution (Debian's adds "(Debian)": postfix
|
||||
# 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
|
||||
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
|
||||
postconf -e \
|
||||
"smtpd_tls_protocols=>=TLSv1.2" \
|
||||
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"disable_vrfy_command=yes" \
|
||||
'smtpd_banner=$myhostname ESMTP'
|
||||
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
||||
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
||||
# uses for sender_login_maps is the BARE login the panel writes into the map;
|
||||
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||
# the server realm, which equals the realm the accounts were created under.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_sasl_glue.c:249, smtpd_sasl_activate(): an empty
|
||||
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
|
||||
postconf -e \
|
||||
"smtpd_sasl_auth_enable=yes" \
|
||||
"smtpd_sasl_type=cyrus" \
|
||||
@@ -194,17 +342,30 @@ postconf -e \
|
||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||
"broken_sasl_auth_clients=yes"
|
||||
|
||||
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
||||
# full address first, then the "@domain" wildcard, so both address modes work
|
||||
# from the same map.
|
||||
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
|
||||
# up in this map with mail_addr_find(), the order being smtpd's and not
|
||||
# texthash's: the full address, the address without its extension, then the
|
||||
# "@domain" wildcard - so both address modes work from the same map (Postfix
|
||||
# 3.10.13, src/smtpd/smtpd_check.c:3933, reject_auth_sender_login_mismatch();
|
||||
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
|
||||
#
|
||||
# recipient_delimiter is said here and not left to the distribution's main.cf:
|
||||
# it is what lets an application listed as invoices@ send as invoices+march@,
|
||||
# and the panel's sender check makes the same second try for the From: line
|
||||
# (extensionDelimiter in internal/milter/sender.go) - the two must agree.
|
||||
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
|
||||
# strip_addr_internal(), for its second try.)
|
||||
postconf -e \
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||
"recipient_delimiter=+" \
|
||||
"queue_directory=${QUEUE_DIR}"
|
||||
|
||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||
# and every authenticated sender address must be owned by its login. NO
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||
# network. This is what makes an open relay impossible.
|
||||
# network. This is what makes an open relay impossible. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_check.c:5076, smtpd_check_rcpt(), :5160-5173: the relay and
|
||||
# recipient lists are evaluated in turn and the first REJECT ends them.)
|
||||
postconf -e \
|
||||
"smtpd_helo_required=yes" \
|
||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
@@ -212,8 +373,25 @@ postconf -e \
|
||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||
|
||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
||||
# journal-milter (level 2) is down.
|
||||
# journal-milter (level 2) is down. Postfix 3.10.13, src/smtpd/smtpd.c:2566,
|
||||
# mail_cmd(), :2624: "450 4.7.1 Error: too much mail from", except for clients
|
||||
# in smtpd_client_event_limit_exceptions (:2619, hogger_list; default
|
||||
# $mynetworks, src/smtpd/smtpd.c:779).
|
||||
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
|
||||
# "Client IP" is the address the container sees: an IPv4 client's own, but
|
||||
# for a client over IPv6 the compose network's gateway, through Docker's
|
||||
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
|
||||
# C-K5; guide, "Rate limiting - level 1").
|
||||
#
|
||||
# mynetworks is said here, loopback only, and not left to the package: it is
|
||||
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
|
||||
# spare. The value is the one Debian's debconf default gives (postfix
|
||||
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
|
||||
# by debian/postfix.postinst:249-262 on a new main.cf, where :180,
|
||||
# fset_all_changed, marks every debconf value to be written; main.cf.in:28 has 127.0.0.0/8
|
||||
# alone), so on this image it changes nothing.
|
||||
postconf -e \
|
||||
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
@@ -221,11 +399,38 @@ postconf -e \
|
||||
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
||||
# monitoring only and is fail-open (default_action=accept): its failure must
|
||||
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
||||
#
|
||||
# The sender check comes first and is strict as well: it holds the From: line
|
||||
# against the login, which is what OpenDKIM then chooses the key by, so a
|
||||
# message it could not judge must not reach the signer. While the panel does
|
||||
# not answer on that socket, mail is deferred (docs/security.md).
|
||||
#
|
||||
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
|
||||
# the panel's own notifications, sent as postmaster@ the hostname. OpenDKIM
|
||||
# signs them with the key of the server's own name; the journal-milter is not
|
||||
# on this chain, since they are not an application's mail. Bounces Postfix
|
||||
# generates itself do not pass it (internal_mail_filter_classes is empty).
|
||||
#
|
||||
# Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
|
||||
# Milter applications" (the braces, Postfix 3.0 and later) and :449, "Signing
|
||||
# internally-generated bounce messages"; src/milter/milter.c:399-564,
|
||||
# milter_*_event() and milter_message(): milters are asked in list order and
|
||||
# the first non-empty reply ends the round; src/global/mail_params.h:3599,
|
||||
# DEF_INT_FILT_CLASSES INT_FILT_CLASS_NONE.
|
||||
postconf -e \
|
||||
"milter_protocol=6" \
|
||||
"milter_default_action=tempfail" \
|
||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters="
|
||||
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }"
|
||||
|
||||
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
|
||||
# address. The panel's milters judge every message by that address (an
|
||||
# application's client-address list) and record it (the inbound journal), and
|
||||
# the address given once at connect is not there for a connection's later
|
||||
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
|
||||
# 3.10.13, src/global/mail_params.h:3532-3534, DEF_MILT_MAIL_MACROS.
|
||||
postconf -e \
|
||||
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
|
||||
|
||||
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
||||
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||
@@ -233,7 +438,8 @@ postconf -e \
|
||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||
# but within seconds rather than minutes. Values are well above any healthy
|
||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||
# operation.
|
||||
# operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
|
||||
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
|
||||
postconf -e \
|
||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||
@@ -243,8 +449,26 @@ postconf -e \
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# and the Cyrus config outside any chroot.
|
||||
# The names smtps, submission and smtp need no /etc/services: Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4450-4451, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
|
||||
# smtps=submissions=465, submission=587" (src/util/find_inet.c:94-99,
|
||||
# find_inet_port() via filter_known_tcp_port()).
|
||||
#
|
||||
# smtpd_hide_client_session=yes on both submission services (owner's
|
||||
# decision, 2026-10-15): the Received: header Postfix adds names neither the
|
||||
# application's HELO name, reverse name and address, nor its TLS session,
|
||||
# SASL login or the protocol - only "Received: by <myhostname> (Postfix) id
|
||||
# <queue id>", "for <rcpt>" when there is one recipient, and the date
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd.c:3495, the client part written only
|
||||
# when the parameter is off; :3627-3645, the "by" line opening the header
|
||||
# when it is on). proto/postconf.proto:19580-19594: "may be used for the
|
||||
# port 587 and 465 MUA services"; port 25 must keep the default, RFC 5321's
|
||||
# trace, and does (smtp/inet below sets nothing). The queue id stays in the
|
||||
# header, so a complaint about a message still leads to its row in the
|
||||
# panel's log and to smtpd's "client=" line in mail.log.
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"smtps/inet/smtpd_hide_client_session=yes" \
|
||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
@@ -252,10 +476,15 @@ postconf -P \
|
||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
||||
# after disabling it does not leave the port listening.
|
||||
# after disabling it does not leave the port listening. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:6525-6526, pre_jail_init(): smtpd_tls_security_level=encrypt
|
||||
# sets enforce_tls, which implies smtpd_tls_auth_only (:6547);
|
||||
# src/postconf/postconf_edit.c:319, pcf_edit_master(): -MX drops the matched
|
||||
# entry, continuation lines included.)
|
||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"submission/inet/smtpd_hide_client_session=yes" \
|
||||
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
@@ -264,7 +493,9 @@ else
|
||||
fi
|
||||
|
||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||
# Debian's stock master.cf enables smtp/inet (postfix 3.10.13-0+deb13u1,
|
||||
# debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
|
||||
# uncommented; conf/master.cf:12). When both flags are off, remove
|
||||
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||
@@ -289,13 +520,35 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
# postconf -P refuses them ("unmatched service_name/type") and leaves a
|
||||
# bare "pipe", which dies with "fatal: missing user= command-line
|
||||
# attribute" on every report. They go into the service line itself.
|
||||
# (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
|
||||
# Postfix 3.10.13: src/postconf/postconf_master.c:1029,
|
||||
# pcf_edit_master_param(), -P writes "-o name=value" and nothing else;
|
||||
# postconf_edit.c:617 warns "unmatched service_name/type" for a service
|
||||
# master.cf does not have; src/pipe/pipe.c:867, get_service_attr(),
|
||||
# :1018: "missing user= command-line attribute".)
|
||||
# ${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}'
|
||||
# learns which report address the message came to from its argument
|
||||
# (man/man8/pipe.8:289, ${recipient}: as many arguments as recipients).
|
||||
# One at a time (the 1 before "pipe"): reports are in no hurry, every run
|
||||
# writes to the database the panel writes to, and a hundred of them in
|
||||
# parallel - Postfix's default - is what a flood of reports would start.
|
||||
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - 1 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.
|
||||
# export_environment is a list split at whitespace in which Postfix
|
||||
# expands "$": a path with either would reach the command as another
|
||||
# path, and it would quietly open a database of its own. (Postfix 3.10.13,
|
||||
# man/man8/pipe.8:417, export_environment; src/global/mail_params.h:2650,
|
||||
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/util/clean_env.c. The
|
||||
# parameter is main.cf's, so every Postfix process exports these too.)
|
||||
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
|
||||
*[[:space:]]* | *'$'*)
|
||||
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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}"
|
||||
@@ -308,7 +561,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||
fi
|
||||
# Comma, not a space, between check_recipient_access and its table: see
|
||||
# master_cf_set_smtp_inet_o.
|
||||
# master_cf_set_smtp_inet_o (master.5, "-o name=value (short form)": "use
|
||||
# commas instead of spaces if the parameter supports that").
|
||||
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||
else
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
@@ -326,10 +580,18 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
# relay domain too (the server's own name), so their table is a recipient
|
||||
# table as well; without it they are "User unknown in relay recipient
|
||||
# table" and no report is ever accepted while both features are on.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(),
|
||||
# :5196-5200: after the lists, check_recipient_rcpt_maps() runs unless
|
||||
# the status is REJECT - an OK from check_recipient_access does not
|
||||
# skip it.)
|
||||
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||
fi
|
||||
# The panel writes the same "[host]:port" into transport and
|
||||
# tls_policy: the policy lookup key is the verbatim next-hop, port and
|
||||
# brackets included (Postfix 3.10.13, README_FILES/TLS_README:914, "TLS
|
||||
# policy table" note: ":smtp", ":25" and no port are different keys).
|
||||
postconf -e \
|
||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||
@@ -345,10 +607,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
# would file inbound mail into the outbound send log under the *sender's*
|
||||
# domain — polluting Deliveries and letting a forged From: consume that
|
||||
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
||||
# here: either the optional antispam milter, or nothing at all.
|
||||
# here: the optional antispam milter, then - with the inbound relay on - the
|
||||
# panel's inbound journal milter, or nothing at all. (Postfix 3.10.13,
|
||||
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
|
||||
# src/smtpd/smtpd.c:3475 hands its own milter list to cleanup, milter_send(),
|
||||
# and src/cleanup/cleanup_milter.c:1997, cleanup_milter_receive(), uses it.)
|
||||
INBOUND_MILTERS=""
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||
# A glob's "*" matches anything, so the shape alone would pass
|
||||
# "inet:a:1},{unix:/elsewhere": characters outside the set are refused
|
||||
# first, then the shape is checked.
|
||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||
*[!A-Za-z0-9._:/-]*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
exit 1
|
||||
;;
|
||||
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
@@ -364,6 +637,32 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
esac
|
||||
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
# Last in the chain: Postfix hands a message to its milters one after
|
||||
# another at end-of-message, so this one reads the verdict headers the
|
||||
# filter added and sees only what the filter let through. It watches and
|
||||
# never refuses, and must never hold up someone else's mail: accept when
|
||||
# it is down. A comma, not a space, between the two - see
|
||||
# master_cf_set_smtp_inet_o.
|
||||
# Postfix 3.10.13: src/milter/milter.c:564, milter_message(), sends the
|
||||
# message to each milter in list order; src/milter/milter8.c:960,
|
||||
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER,
|
||||
# :1424) to the queue file at once, and the next milter8_message()
|
||||
# (:2544) reads that file with rec_get() (:2612), which follows the
|
||||
# added records.
|
||||
case "${INBOUND_JOURNAL_SOCK}" in
|
||||
*[!A-Za-z0-9._/-]*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
/[A-Za-z0-9._/-]*) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
INBOUND_MILTERS="${INBOUND_MILTERS:+${INBOUND_MILTERS},}{unix:${INBOUND_JOURNAL_SOCK},default_action=accept}"
|
||||
fi
|
||||
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
@@ -380,8 +679,34 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
fi
|
||||
fi
|
||||
|
||||
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
|
||||
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
|
||||
# into the inbound journal and has to tell the two apart. (Postfix 3.10.13,
|
||||
# src/global/mail_task.c:56, mail_task(): the log tag is syslog_name "/"
|
||||
# program name. Only smtpd carries it: cleanup and the rest are other
|
||||
# services and log as postfix/.)
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||
# smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence. normalize
|
||||
# takes a bare <LF> as a line end but ends DATA only at the standard
|
||||
# <CR><LF>.<CR><LF>: a "." line that a bare <LF> precedes or ends is
|
||||
# dropped, not taken as the end of the message (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:1693-1694, bare_lf_mask_table, normalize =
|
||||
# BARE_LF_FLAG_WANT_STD_EOD; :3712-3716, the DATA loop; RELEASE_NOTES-3.9:
|
||||
# 166-167, "the recommended setting "smtpd_forbid_bare_newline
|
||||
# = normalize""). Since 3.9 normalize is also the default (src/smtpd/
|
||||
# smtpd.c:830, "Postfix >= 3.9: normalize"; src/global/mail_params.h:4313,
|
||||
# DEF_SMTPD_FORBID_BARE_LF "normalize"); the line stays so that port 25
|
||||
# does not depend on a default (it came in 2.6.1, while the image's
|
||||
# Postfix 3.7.11 defaulted to "no").
|
||||
# Its exclusions default to $mynetworks (mail_params.h:4316), loopback
|
||||
# only here (above), so no client is spared. The submission ports take
|
||||
# the default, normalize as well since this Postfix: an application whose
|
||||
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
|
||||
# end there (own-mta-builds, § 5: their smuggled transaction would still
|
||||
# be the authenticated session's, so they were left to the default).
|
||||
postconf -P \
|
||||
"smtp/inet/smtpd_forbid_bare_newline=normalize" \
|
||||
"smtp/inet/syslog_name=postfix/inbound" \
|
||||
"smtp/inet/smtpd_sasl_auth_enable=no" \
|
||||
"smtp/inet/smtpd_tls_auth_only=no" \
|
||||
"smtp/inet/smtpd_sender_login_maps=" \
|
||||
@@ -392,6 +717,18 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
||||
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||
master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
|
||||
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
|
||||
|
||||
# smtpd's -o limit is not the whole of it: cleanup writes the queue file
|
||||
# under its own message_size_limit, main.cf's, which is never set here and
|
||||
# so is 10240000 (Postfix 3.10.13, src/cleanup/cleanup_init.c:444,
|
||||
# cleanup_post_jail(), :455, set_file_limit(); src/global/mail_params.h:2013,
|
||||
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
|
||||
# (assumptions C-P31). cleanup gets port 25's limit when it is larger;
|
||||
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c:3721,
|
||||
# :3964, :4136, :4250, the DATA and BDAT size checks).
|
||||
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
|
||||
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
|
||||
fi
|
||||
else
|
||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
@@ -409,13 +746,44 @@ fi
|
||||
# Inside a container the chroot buys little (the container is the isolation
|
||||
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
|
||||
# own smtps/submission services are already n; this covers the delivery agents
|
||||
# and the rest.
|
||||
# and the rest. (postfix 3.10.13-0+deb13u1: debian/patches/40_chroot_by_default.diff
|
||||
# sets chroot y. chroot(2) also needs CAP_SYS_CHROOT, which
|
||||
# deploy/docker-compose.yml drops with cap_drop: ALL - capabilities(7).)
|
||||
#
|
||||
# Debian's chroot setup now runs on every "postfix check" and "postfix
|
||||
# start-fg": conf/post-install:587 (debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch) calls
|
||||
# /usr/lib/postfix/configure-instance.sh at the end of create-missing, and
|
||||
# that copies etc/localtime, etc/resolv.conf, etc/hosts and the like into
|
||||
# $queue_directory/etc - here /data/postfix/queue/etc - whether a service is
|
||||
# chrooted or not (debian/configure-instance.in:80-91, before its
|
||||
# need_chroot test at :93). With nothing chrooted those copies are read by
|
||||
# nobody, so the image turns the sync off with SYNC_CHROOT= in
|
||||
# /etc/default/postfix (build/Dockerfile; configure-instance.in:22-24, :44,
|
||||
# "[ -n "$SYNC_CHROOT" ] || exit 0"; debian/README.Debian:136-138).
|
||||
postconf -F "*/*/chroot=n"
|
||||
|
||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||
# only — both are safe because TLS is mandatory before auth on every port.
|
||||
# The directory is said here, cyrus_sasl_config_path, and not left to the
|
||||
# package's main.cf. Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:227-232,
|
||||
# xsasl_cyrus_server_init(): a non-empty cyrus_sasl_config_path goes to Cyrus
|
||||
# with sasl_set_path(SASL_PATH_TYPE_CONFIG, ...), and :246,
|
||||
# sasl_server_init(callbacks, path_info) with smtpd_sasl_path=smtpd
|
||||
# (src/smtpd/smtpd_sasl_glue.c:188-189) names smtpd.conf there; the default
|
||||
# is empty (src/global/mail_params.h:1690, DEF_CYRUS_CONF_PATH ""), and then
|
||||
# Cyrus looks in its own path, /etc/sasl2:/etc/sasl:/usr/lib/<triplet>/sasl2:
|
||||
# /usr/lib/sasl2 (cyrus-sasl2 2.1.28+dfsg1, debian/rules:96, --with-configdir;
|
||||
# lib/common.c:210, sasl_set_path()), which this file is not in. Bookworm's
|
||||
# package found it with a patch (07_sasl_config.diff, gone in 3.10.13); trixie's
|
||||
# writes cyrus_sasl_config_path = /etc/postfix/sasl into a new main.cf
|
||||
# (postfix 3.10.13-0+deb13u1, debian/main.cf.in:71) and into an upgraded one
|
||||
# only when it is empty (debian/postfix.postinst:88-97). The keys: Cyrus SASL
|
||||
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
|
||||
# sasldb_path, mech_list).
|
||||
postconf -e "cyrus_sasl_config_path=/etc/postfix/sasl"
|
||||
mkdir -p /etc/postfix/sasl
|
||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||
pwcheck_method: auxprop
|
||||
@@ -425,7 +793,11 @@ mech_list: PLAIN LOGIN
|
||||
EOF
|
||||
|
||||
# Validate the generated configuration; fail loudly if postconf produced
|
||||
# anything Postfix rejects, before the wrapper tries to start it.
|
||||
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
|
||||
# 3.10.13, conf/postfix-script:292, "check": "check-fatal || exit 1"; check-fatal
|
||||
# also runs "post-install create-missing" (:314), which builds the queue tree
|
||||
# and, in Debian's package, runs configure-instance.sh - see the chroot note
|
||||
# above.)
|
||||
set +e
|
||||
check_out=$(postfix check 2>&1)
|
||||
ec=$?
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# The reload the panel asks supervisord for (program postfix-reload,
|
||||
# build/supervisord.conf; internal/supervisor RunOnce reads its exit status).
|
||||
#
|
||||
# Exit 75 says no Postfix master runs - it is stopped, or still in its start
|
||||
# behind the milter wait (build/postfix-wrapper.sh) - and the master that
|
||||
# starts next reads the files as they are now, so the panel takes that as
|
||||
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
|
||||
# 3.10.13, conf/postfix-script:266-279, "reload": exit 1 when not running or
|
||||
# when postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
|
||||
# -t" exits 0, silently, while no master holds the lock (src/master/master.c:495-499,
|
||||
# test_lock). A master that stops between the two is a failure the panel
|
||||
# reports - the rarer mistake (review of stage 2c, F5). Since Postfix 3.8 the
|
||||
# reload also writes "refreshing the Postfix mail system" to stderr, which
|
||||
# goes to the container's log (postfix-wrapper.sh, the note on 3.8's stderr
|
||||
# logging); the panel reads only the exit status.
|
||||
d=$(postconf -h daemon_directory) || exit 1
|
||||
if "$d/master" -t 2>/dev/null; then
|
||||
echo "postfix-reload: Postfix is not running; its start reads the files" >&2
|
||||
exit 75
|
||||
fi
|
||||
exec /usr/sbin/postfix reload
|
||||
@@ -2,22 +2,36 @@
|
||||
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
||||
# reach the milter sockets before they are listening.
|
||||
#
|
||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||
# an explicit startup failure instead of a relay running blind.
|
||||
# It blocks until the milter sockets — OpenDKIM, the panel's sender check and
|
||||
# journal-milter and, with the inbound relay on, its inbound journal milter —
|
||||
# are present, then runs `postfix start-fg` and stays its parent. If they are
|
||||
# not ready within the timeout it exits non-zero WITHOUT starting Postfix.
|
||||
# supervisord counts that exit as one that came too quickly - the program's
|
||||
# startsecs is the timeout plus ten seconds (build/entrypoint.sh) - so after
|
||||
# startretries it is FATAL and crashexit takes the container down, instead of
|
||||
# a relay running blind (supervisor 4.2.5, supervisor/process.py,
|
||||
# Subprocess.finish(): an exit before startsecs is "Exited too quickly" and
|
||||
# goes to BACKOFF; transition(): BACKOFF past startretries is FATAL;
|
||||
# assumptions C-V6).
|
||||
#
|
||||
# This handles cold start only. Runtime milter failures after a successful start
|
||||
# are governed by fail-open (milter_default_action).
|
||||
# This handles cold start only. Once Postfix runs, a milter that does not
|
||||
# answer costs what its default_action says (build/postfix-config.sh): mail is
|
||||
# deferred for OpenDKIM and the sender check, and passes the journal-milter.
|
||||
set -eu
|
||||
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
INTERVAL=1
|
||||
|
||||
SOCKS="$OPENDKIM_SOCK $JOURNAL_SOCK $SENDER_SOCK"
|
||||
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
|
||||
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
fi
|
||||
|
||||
elapsed=0
|
||||
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
for sock in $SOCKS; do
|
||||
while [ ! -S "$sock" ]; do
|
||||
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
||||
@@ -29,5 +43,68 @@ for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
echo "postfix-wrapper: milter socket ready: $sock"
|
||||
done
|
||||
|
||||
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||
exec postfix start-fg
|
||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
||||
# Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
|
||||
# whose "start-fg" (:200-210) execs master only as PID 1 and otherwise runs
|
||||
# "master -s" as its child; master then makes itself a session leader
|
||||
# (src/master/master.c:441, setsid()). A signal supervisord sends this
|
||||
# program never reaches master
|
||||
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
|
||||
# alone without stopasgroup, and master is not in its group anyway): before,
|
||||
# a stop left master running - a restart of the program then found Postfix
|
||||
# "already running", and a stop of the container cut Postfix off with
|
||||
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
|
||||
# "postfix stop" (conf/postfix-script:237-253, "stop": TERM to master, then
|
||||
# waits up to five seconds and kills its group; src/master/master_sig.c:205,
|
||||
# master_sigdeath(): "terminating on signal 15"). Debian's redirect of
|
||||
# "postfix start" to systemd (debian/patches/
|
||||
# debian-re-run-startup-through-systemd.patch, conf/postfix-script:170-189)
|
||||
# leaves start-fg alone and needs /run/systemd/system, which this container
|
||||
# has not.
|
||||
#
|
||||
# Since Postfix 3.8 postfix(1) and postlog(1) - postfix-script's $INFO, $WARN
|
||||
# and $FATAL - write their messages to stderr even when it is not a terminal
|
||||
# (src/postfix/postfix.c:546, msg_vstream_init() no longer under isatty();
|
||||
# RELEASE_NOTES-3.8:114-115, [Incompat 20230308]), besides maillog_file. So
|
||||
# "starting the Postfix mail system", "stopping ..." and check-warn's
|
||||
# warnings now reach the container's log through this program's stderr
|
||||
# (build/supervisord.conf) as well as /data/log/mail.log; nothing reads them
|
||||
# there but a person.
|
||||
#
|
||||
# "postfix start-fg" spends its first seconds in its checks before master
|
||||
# runs; a stop asked then would find nothing to stop, so it is asked again
|
||||
# until the child is gone. "master -t" exits 0, silently, while no master
|
||||
# holds the lock (src/master/master.c:495-499, test_lock).
|
||||
daemon_directory=$(postconf -h daemon_directory)
|
||||
child=
|
||||
stop_asked=
|
||||
stop_postfix() {
|
||||
if [ -z "$child" ]; then
|
||||
# Between "&" and "child=$!": stopped right after the pid is known.
|
||||
stop_asked=1
|
||||
return
|
||||
fi
|
||||
trap '' TERM INT
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
if "$daemon_directory/master" -t 2>/dev/null; then
|
||||
sleep 1
|
||||
else
|
||||
postfix stop || true
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
trap stop_postfix TERM INT
|
||||
postfix start-fg &
|
||||
child=$!
|
||||
if [ -n "$stop_asked" ]; then
|
||||
stop_postfix
|
||||
fi
|
||||
# A trapped signal makes wait return at once, the trap is taken right after
|
||||
# (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits.
|
||||
# Otherwise wait returns when Postfix ended on its own: its status is this
|
||||
# program's, and supervisord restarts it.
|
||||
status=0
|
||||
wait "$child" || status=$?
|
||||
echo "postfix-wrapper: postfix exited with status $status" >&2
|
||||
exit "$status"
|
||||
|
||||
+93
-25
@@ -1,12 +1,24 @@
|
||||
; SelfPost process supervision.
|
||||
;
|
||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||
; blocks until both milter sockets are ready before starting Postfix.
|
||||
; the sender check's and the journal-milter's sockets), then the Postfix
|
||||
; wrapper — which additionally blocks until the milter sockets are ready
|
||||
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
|
||||
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
|
||||
; supervisord.py, runforever(), transitions every group in that order in one
|
||||
; pass, with no wait for a program to be ready.)
|
||||
;
|
||||
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||
; event listener brings the whole container down so Docker's restart policy can
|
||||
; recreate it cleanly, rather than lingering with a dead component.
|
||||
; If a managed process of the mail path exhausts its restart retries (FATAL),
|
||||
; or exits unexpectedly five times in ten minutes, the crashexit event listener
|
||||
; brings the whole container down so Docker's restart policy starts it again
|
||||
; (the same container: docs.docker.com, "Start containers automatically",
|
||||
; restart policies), rather than lingering with a dead component. A program
|
||||
; reaches FATAL only through BACKOFF, by exits that come before its startsecs
|
||||
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
|
||||
; transition()): an exit after it is EXITED and simply restarted, for ever -
|
||||
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
|
||||
; postfix-reload's and syslog-relay's failures are left on Server > Health
|
||||
; (build/crashexit.py).
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
@@ -16,15 +28,20 @@ logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
|
||||
; unreachable by world; only root and the two SelfPost services are in the
|
||||
; group. No HTTP auth is configured because access is gated by socket
|
||||
; permissions, not the network.
|
||||
; The control socket is reachable by the `supervisor` group, whose one member
|
||||
; is the panel (the unprivileged `panel` user): it asks supervisord to signal
|
||||
; OpenDKIM after it rewrites the DKIM tables, to run the Postfix reload, and
|
||||
; for the processes' state. Whoever reaches the socket can stop, start or
|
||||
; restart any program, so it is not the shared `selfpost` group's: OpenDKIM
|
||||
; and Postfix are in that one for the files under /data, and a fault in either
|
||||
; must not reach every other program (audit 2026-10-12, stage 5). Not
|
||||
; reachable by world. No HTTP auth is configured because access is gated by
|
||||
; socket permissions, not the network. (supervisor 4.2.5,
|
||||
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
|
||||
; mode and owner; without username and password no authentication is asked.)
|
||||
[unix_http_server]
|
||||
file=/run/supervisor.sock
|
||||
chown=root:selfpost
|
||||
chown=root:supervisor
|
||||
chmod=0770
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
@@ -33,8 +50,29 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisor.sock
|
||||
|
||||
; The container's /dev/log (a link to the socket below, build/entrypoint.sh):
|
||||
; what OpenDKIM - which logs through syslog(3) and nothing else - sends there
|
||||
; comes out on stdout, into the container's log (internal/syslogrelay; audit
|
||||
; 2026-10-12, L30). Before OpenDKIM, so its first lines are not lost; a
|
||||
; message sent while nothing listens is dropped by syslog(3), not queued.
|
||||
[program:syslog-relay]
|
||||
command=/usr/local/bin/panel -syslog-relay=/run/syslog/log
|
||||
user=panel
|
||||
priority=50
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:opendkim]
|
||||
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||
; It waits, five seconds at most, for the syslog relay's socket: supervisord
|
||||
; starts programs in priority order without waiting for one to be ready, and
|
||||
; OpenDKIM's start line would go nowhere (review of audit stage 5). exec keeps
|
||||
; the supervised pid OpenDKIM's.
|
||||
command=/bin/sh -c 'i=0; while [ ! -S /run/syslog/log ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done; exec /usr/sbin/opendkim -f -x /etc/opendkim.conf'
|
||||
priority=100
|
||||
autostart=true
|
||||
autorestart=true
|
||||
@@ -44,6 +82,10 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; user=panel also gives the panel its supplementary groups, selfpost and
|
||||
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
|
||||
; drop_privileges(), os.setgroups() from the user's memberships in
|
||||
; grp.getgrall().
|
||||
[program:panel]
|
||||
command=/usr/local/bin/panel
|
||||
user=panel
|
||||
@@ -56,12 +98,22 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; startsecs is MILTER_WAIT_TIMEOUT plus ten seconds (build/entrypoint.sh): the
|
||||
; wrapper's exit when the milter sockets never come counts as too quick, and
|
||||
; so does a Postfix that will not start. The price: for that long after each
|
||||
; start the program is STARTING, not RUNNING (supervisor 4.2.5,
|
||||
; supervisor/options.py, processes_from_section(): startsecs is expanded like
|
||||
; any value, %(ENV_x)s from supervisord's environment; assumptions C-V6).
|
||||
; stopwaitsecs covers "postfix stop", which waits five seconds for master
|
||||
; before it kills (build/postfix-wrapper.sh).
|
||||
[program:postfix]
|
||||
command=/usr/local/bin/postfix-wrapper.sh
|
||||
priority=300
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=%(ENV_POSTFIX_STARTSECS)s
|
||||
startretries=3
|
||||
stopwaitsecs=15
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
@@ -73,11 +125,18 @@ stderr_logfile_maxbytes=0
|
||||
; foreground process never reaches it (unlike OpenDKIM, which runs in the
|
||||
; foreground as the supervised process itself). Instead the panel asks
|
||||
; supervisord — over the group-accessible control socket — to run this program,
|
||||
; which executes the canonical `postfix reload` as root. autostart is off (it
|
||||
; only runs on demand) and a fast, clean exit is expected, so it never trips the
|
||||
; crashexit listener.
|
||||
; which runs the canonical `postfix reload` as root - or exits 75 when no
|
||||
; master runs (build/postfix-reload.sh). autostart is off (it only runs on
|
||||
; demand). A program that cannot even be spawned twice in a row ends FATAL
|
||||
; (startretries=1); crashexit leaves that to the page that asked for the
|
||||
; reload, which reports it (build/crashexit.py; review of stage 2c, F6). (start-fg: see build/postfix-wrapper.sh. supervisor
|
||||
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
|
||||
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
|
||||
; never FATAL - with its exit status kept, which the panel reads
|
||||
; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first
|
||||
; run, EXITED after it; assumptions C-V10.)
|
||||
[program:postfix-reload]
|
||||
command=/usr/sbin/postfix reload
|
||||
command=/usr/local/bin/postfix-reload.sh
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
@@ -88,8 +147,9 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||
; TLS-certificate refresh. Once a day it compares the certificate the
|
||||
; reverse-proxy renews in the read-only mount with the copy Postfix reads, and
|
||||
; when they differ copies it again and runs `postfix reload`.
|
||||
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:cert-reload]
|
||||
@@ -103,23 +163,31 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
|
||||
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
|
||||
; the config is group/other-writable (logrotate would ignore it silently);
|
||||
; autorestart surfaces BACKOFF on the Status page.
|
||||
; Periodic logrotate for /data/log/mail.log (daily, 14 files kept). Runs as
|
||||
; root so logrotate can read/rotate the log. Exits non-zero at once when the
|
||||
; config is group/other-writable (logrotate would ignore it silently): with
|
||||
; startsecs=1 that exit is too quick; once startretries are spent the program
|
||||
; is FATAL, and Server > Health shows it. With startsecs=0 it was restarted
|
||||
; every second for ever, RUNNING and EXITED in turn (supervisor 4.2.5,
|
||||
; supervisor/process.py, Subprocess.finish(); assumptions C-V5). crashexit
|
||||
; leaves this FATAL alone: logrotate is not on the mail path.
|
||||
[program:logrotate]
|
||||
command=/usr/local/bin/logrotate-loop.sh
|
||||
priority=400
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=0
|
||||
startsecs=1
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
|
||||
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
|
||||
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
|
||||
[eventlistener:crashexit]
|
||||
command=/usr/local/bin/crashexit.py
|
||||
events=PROCESS_STATE_FATAL
|
||||
events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||
|
||||
func runDMARCIngestMode() error {
|
||||
cfg := loadConfig()
|
||||
if !cfg.dmarcEnabled {
|
||||
@@ -27,18 +25,69 @@ 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
|
||||
}
|
||||
// Not a report this relay keeps. It is dropped: deferring would have
|
||||
// Postfix try it again for days, and a bounce would go to an envelope
|
||||
// sender anyone can write - the report addresses are public. Postfix
|
||||
// 3.10.13: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
|
||||
// ... service ...)" (src/global/pipe_command.c, pipe_command();
|
||||
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
|
||||
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
|
||||
// to the sender (src/qmgr/qmgr_active.c, src/global/bounce.c).
|
||||
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||
}
|
||||
return err
|
||||
logRejected(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logRejected leaves the reason in the mail log. Postfix puts the start of
|
||||
// what a delivery command wrote - about a hundred bytes - into its own line
|
||||
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
|
||||
// so the reason goes out first and bare: no prefix, no date, no recipient,
|
||||
// all of which that line already has. Postfix 3.10.13, src/pipe/pipe.c,
|
||||
// eval_command_status(): " (%.100s)" of the output, which
|
||||
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
|
||||
// no use here: pipe(8) starts the command without the group that may write to
|
||||
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
|
||||
// user's one group). Nor does postlog bring the group with it: upstream
|
||||
// installs it set-gid, Debian's package does not - measured in the image,
|
||||
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
|
||||
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
|
||||
// postqueue (assumptions A-43). postfix 3.10.13-0+deb13u1 is the same by its
|
||||
// source: debian/postfix.postinst adds statoverrides for /usr/sbin/postdrop
|
||||
// and /usr/sbin/postqueue alone, and nothing in debian/ makes postlog set-gid.
|
||||
func logRejected(reason error) {
|
||||
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
|
||||
}
|
||||
|
||||
// logSafe makes a reason fit for a log line. Parts of it are the sender's own
|
||||
// bytes - a header line, a file name inside an archive, a piece of XML - so it
|
||||
// is cut short and anything that is not printable ASCII becomes "?", whatever
|
||||
// Postfix would have done with it.
|
||||
func logSafe(s string) string {
|
||||
const max = 200
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return '?'
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Postfix 3.10.13, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
|
||||
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
|
||||
// src/local/command.c sets "RECIPIENT".
|
||||
// Without it the check that a report came to its own domain's address is
|
||||
// skipped.
|
||||
func ingestRecipient(args []string) string {
|
||||
|
||||
@@ -18,3 +18,17 @@ func TestIngestRecipient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSafe(t *testing.T) {
|
||||
got := logSafe("zip entry \"a\nstatus=bounced\tx\" \x1b[31m é")
|
||||
if want := "zip entry \"a?status=bounced?x\" ?[31m ?"; got != want {
|
||||
t.Errorf("logSafe = %q, want %q", got, want)
|
||||
}
|
||||
long := make([]byte, 1000)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := logSafe(string(long)); len(got) != 200 {
|
||||
t.Errorf("logSafe kept %d bytes of 1000, want 200", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
+118
-152
@@ -1,172 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
// The environment the image reads and the environment the documents describe
|
||||
// are compared as they are, not as lists kept by hand beside them (audit
|
||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
||||
// other and with nothing else). Read: what the panel's code asks of the
|
||||
// environment (cmd/panel and internal, tests aside) and what the start-up
|
||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
||||
// operator's table in guide.md § Environment variables, with the paragraph
|
||||
// under it, and the internal list in architecture.md § Configuration.
|
||||
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"POSTFIX_QUEUE_DIR",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"INBOUND_ANTISPAM_MILTER",
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||
// envRead is every variable the code and the scripts read.
|
||||
func envRead(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
||||
if err != nil || len(scripts) == 0 {
|
||||
t.Fatalf("no build scripts: %v", err)
|
||||
}
|
||||
for _, path := range scripts {
|
||||
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// section is the text of doc from the first line that starts with start up
|
||||
// to the next that starts with any of ends.
|
||||
func section(t *testing.T, doc, start string, ends ...string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(doc, "\n"+start)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q", start)
|
||||
}
|
||||
rest := doc[i+1+len(start):]
|
||||
end := len(rest)
|
||||
for _, e := range ends {
|
||||
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
|
||||
end = j
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// envDocumented is every variable the documents name, and where.
|
||||
func envDocumented(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
keys := map[string]string{}
|
||||
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
||||
keys[m[1]] = "guide.md § Environment variables"
|
||||
}
|
||||
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
||||
if _, ok := keys[m[1]]; !ok {
|
||||
keys[m[1]] = "architecture.md § Configuration"
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
||||
doc := envDocumented(t)
|
||||
var missing []string
|
||||
for key := range envRead(t) {
|
||||
if _, ok := doc[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(missing)
|
||||
for _, key := range missing {
|
||||
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range buildScriptKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
||||
read := envRead(t)
|
||||
var stale []string
|
||||
for key, where := range envDocumented(t) {
|
||||
if !read[key] {
|
||||
stale = append(stale, key+" ("+where+")")
|
||||
}
|
||||
}
|
||||
slices.Sort(stale)
|
||||
for _, key := range stale {
|
||||
t.Errorf("%s is documented but nothing reads it", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||
read := append([]string{}, loadConfigKeys...)
|
||||
read = append(read, buildScriptKeys...)
|
||||
slices.Sort(read)
|
||||
read = slices.Compact(read)
|
||||
|
||||
for _, key := range documentedKeys() {
|
||||
if !slices.Contains(read, key) {
|
||||
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||
// The parsers find what they are for: a key of each kind is among the read.
|
||||
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
||||
read := envRead(t)
|
||||
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
||||
if !read[key] {
|
||||
t.Errorf("%s not found among the keys read", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-8
@@ -24,6 +24,7 @@ type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
Inbound *inbound.Service
|
||||
Lists *inbound.Lists
|
||||
DMARC *dmarc.Service
|
||||
pf *postfix.Postfix
|
||||
odk *domain.OpenDKIM
|
||||
@@ -36,11 +37,15 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||
// The panel's notifications are sent as postmaster@ the server's own name,
|
||||
// and signed as it.
|
||||
domains.SetServerName(cfg.hostname)
|
||||
inb := inbound.NewService(st, pf)
|
||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||
return &mailStack{
|
||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
Lists: inbound.NewLists(st, cfg.antispamDir),
|
||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +62,11 @@ func (m *mailStack) Resync() error {
|
||||
if err := m.Inbound.Resync(); err != nil {
|
||||
return fmt.Errorf("inbound maps resync: %w", err)
|
||||
}
|
||||
// The filter's configuration names all six list files; they exist from
|
||||
// the first start, empty until an entry is added.
|
||||
if err := m.Lists.Sync(); err != nil {
|
||||
return fmt.Errorf("filter lists resync: %w", err)
|
||||
}
|
||||
}
|
||||
if m.dmarcEnabled {
|
||||
if err := m.DMARC.Resync(); err != nil {
|
||||
@@ -89,9 +99,11 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
ms := newMailStack(cfg, st)
|
||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||
// effective config, including a manual override; the panel keeps this
|
||||
// snapshot for the process lifetime (architecture.md).
|
||||
// snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
|
||||
// postconf(1): without -d or -n it shows main.cf's value or the default,
|
||||
// and -h drops the "name = " label; values are not $-expanded without -x.
|
||||
retryPolicy := postfix.LoadRetryPolicy()
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
@@ -102,8 +114,11 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SenderSocket: cfg.senderSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||
@@ -114,9 +129,31 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||
DMARCEnabled: cfg.dmarcEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||
|
||||
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
||||
}, cfg.setupTokenPath)
|
||||
}
|
||||
|
||||
// newHTTPServer bounds what a client may hold the panel's server for (audit
|
||||
// 2026-10-12, L14): the request with its body - a form, or a domain export
|
||||
// of 1 MiB at most - in 30 s; the answer in 60 s, the slowest page being one
|
||||
// that asks DNS; an idle kept-alive connection 2 min. The full backup moves
|
||||
// its own write deadline to two hours: it streams for as long as the archive
|
||||
// takes (handlers.HandleBackup). net/http, Go 1.26: ReadTimeout covers the
|
||||
// header and the body, WriteTimeout runs from the end of the header's read
|
||||
// to the end of the response's write, IdleTimeout is the wait for the next
|
||||
// request on a kept-alive connection.
|
||||
func newHTTPServer(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
@@ -129,11 +166,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The panel's server bounds every phase a client could hold it in (audit
|
||||
// 2026-10-12, L14); before, only the header's read was.
|
||||
func TestHTTPServerHasItsTimeouts(t *testing.T) {
|
||||
s := newHTTPServer(":0", nil)
|
||||
for name, d := range map[string]time.Duration{
|
||||
"ReadHeaderTimeout": s.ReadHeaderTimeout,
|
||||
"ReadTimeout": s.ReadTimeout,
|
||||
"WriteTimeout": s.WriteTimeout,
|
||||
"IdleTimeout": s.IdleTimeout,
|
||||
} {
|
||||
if d <= 0 {
|
||||
t.Errorf("%s is %v", name, d)
|
||||
}
|
||||
}
|
||||
if s.ReadTimeout < s.ReadHeaderTimeout || s.WriteTimeout < 30*time.Second {
|
||||
t.Errorf("read %v (header %v), write %v: too short for a form or a DNS page", s.ReadTimeout, s.ReadHeaderTimeout, s.WriteTimeout)
|
||||
}
|
||||
}
|
||||
+46
-11
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
@@ -15,31 +16,65 @@ import (
|
||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||
// (architecture.md § Mail path), recording accepted messages into the send
|
||||
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
||||
// here; the protocol handler lives in internal/milter.
|
||||
// in listenMilter; the protocol handler lives in internal/milter.
|
||||
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
socketPath := cfg.journalSocket
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
ln, err := listenMilter(cfg.journalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("journal-milter listening on %s", cfg.journalSocket)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
|
||||
// serveInboundJournal runs the inbound journal milter, the last milter on
|
||||
// port 25 (internal/milter/inbound.go). It exists only while the inbound relay
|
||||
// is on: build/postfix-config.sh attaches the socket under the same condition.
|
||||
func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.inboundJournalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// With a filter attached, the key it signs with. Without the key no
|
||||
// verdict can be told from a sender's own, so none is read: the journal
|
||||
// still records the mail, and the log says why it shows no verdicts.
|
||||
key := ""
|
||||
if cfg.inboundAntispamMilter != "" {
|
||||
if key, err = inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("inbound-journal-milter: no key for the filter's signature, verdicts will not be recorded: %v", err)
|
||||
key = ""
|
||||
}
|
||||
}
|
||||
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
|
||||
return milter.ServeInbound(ctx, ln, st, key)
|
||||
}
|
||||
|
||||
// listenMilter opens a milter's Unix socket where Postfix can reach it.
|
||||
func listenMilter(socketPath string) (net.Listener, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use" (unix(7): the socket
|
||||
// file "must be deleted by the caller when it is no longer needed"; bind(2)
|
||||
// EADDRINUSE).
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
// unix(7): "connecting to a stream socket object requires write
|
||||
// permission on that socket"; inode(7): a file made in a set-group-ID
|
||||
// directory takes the directory's group.
|
||||
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("journal-milter listening on %s", socketPath)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
+146
-28
@@ -19,12 +19,16 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/syslogrelay"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -33,18 +37,32 @@ func main() {
|
||||
log.SetPrefix("dmarc-ingest: ")
|
||||
if err := runDMARCIngestMode(); err != nil {
|
||||
log.Printf("ingest failed: %v", err)
|
||||
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
|
||||
// src/global/pipe_command.c, pipe_command(): a sysexits code maps
|
||||
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
|
||||
// unless the output starts with a "D.S.N text" of its own - the
|
||||
// log prefix above makes sure it does not.
|
||||
os.Exit(75)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
relay := flag.String("syslog-relay", "", "relay syslog(3) datagrams sent to this socket path to stdout, and do nothing else")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(buildinfo.Version)
|
||||
return
|
||||
}
|
||||
if *relay != "" {
|
||||
// The container's /dev/log, for OpenDKIM (internal/syslogrelay;
|
||||
// build/supervisord.conf, [program:syslog-relay]).
|
||||
if err := syslogrelay.Run(*relay, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("panel: ")
|
||||
@@ -59,6 +77,8 @@ func main() {
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
||||
senderSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
@@ -81,6 +101,11 @@ type config struct {
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
// tlsServedCertFile is the certificate Postfix serves (its copy of
|
||||
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
|
||||
// how often build/postfix-cert-reload.sh looks for a renewal.
|
||||
tlsServedCertFile string
|
||||
tlsReloadInterval time.Duration
|
||||
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
@@ -89,6 +114,9 @@ type config struct {
|
||||
saslRealm string
|
||||
postfixDir string
|
||||
deployRoot string
|
||||
// Where the filter lists are written as map files for the external spam
|
||||
// filter; the antispam compose fragment mounts it into the sidecar.
|
||||
antispamDir string
|
||||
|
||||
inboundEnabled bool
|
||||
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
|
||||
@@ -96,7 +124,12 @@ type config struct {
|
||||
// what attaches it; the panel only says so. See antispamAction.
|
||||
inboundAntispamMilter string
|
||||
inboundAntispamAction string
|
||||
dmarcEnabled bool
|
||||
// The inbound journal milter's socket; the default mirrors
|
||||
// build/postfix-config.sh. Served only while the inbound relay is on.
|
||||
inboundJournalSocket string
|
||||
// Retention of the inbound journal until Server > Settings says otherwise.
|
||||
inboundSpamLogRetentionDays int
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -104,6 +137,7 @@ func loadConfig() config {
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"),
|
||||
// Postfix's delivery log, under /data so the lines that resolve a
|
||||
// "queued" send-log row outlive the container. The default must match
|
||||
// maillog_file in build/postfix-config.sh.
|
||||
@@ -141,8 +175,9 @@ func loadConfig() config {
|
||||
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
@@ -157,11 +192,16 @@ func loadConfig() config {
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
|
||||
antispamDir: envDefault("ANTISPAM_DIR", filepath.Join(dataDir, "antispam")),
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
||||
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
|
||||
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
|
||||
inboundJournalSocket: envDefault("INBOUND_JOURNAL_MILTER_SOCKET", "/run/selfpost/inbound-journal.sock"),
|
||||
|
||||
inboundSpamLogRetentionDays: envInt("INBOUND_SPAM_LOG_RETENTION_DAYS", store.InboundSpamLogRetentionDaysDefault),
|
||||
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||
}
|
||||
@@ -183,7 +223,14 @@ func antispamAction(raw string) string {
|
||||
|
||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||
// login resolves to the right account.
|
||||
// login resolves to the right account. Postfix 3.10.13,
|
||||
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
|
||||
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
|
||||
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
|
||||
// plugins/sasldb.c, sasldb_auxprop_lookup(), and common/plugin_common.c,
|
||||
// _plug_parseuser(): a login
|
||||
// without "@" is then looked up in the realm serverFQDN. A SASL_REALM other
|
||||
// than SELFPOST_HOSTNAME does not follow that - assumptions A-48.
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
@@ -228,10 +275,17 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
// The prefix length goes with how the address is written: an
|
||||
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
|
||||
// 128-bit address, and "/32" on it is ::/32 - every address
|
||||
// whose first 32 bits are zero (::1 among them) trusted, and the
|
||||
// host meant not at all (audit 2026-10-12, L4). Go 1.26
|
||||
// net.IPNet.Contains compares an IPv4 client with the mapped
|
||||
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
|
||||
if strings.Contains(cidr, ":") {
|
||||
cidr += "/128"
|
||||
} else {
|
||||
cidr += "/32"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
@@ -244,6 +298,37 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
return nets
|
||||
}
|
||||
|
||||
// openData opens the database the panel runs on. When a backup was unpacked
|
||||
// into /data, the restore guard comes first (architecture.md § Persistence):
|
||||
// the manifest's version must match this binary and the archive must have
|
||||
// been unpacked whole before the database is touched, so neither schema skew
|
||||
// nor half an archive can corrupt the restored state. That first start then
|
||||
// runs resync, and only after it the manifest is consumed: a start that fails
|
||||
// on the way leaves it, and the next start is a restore again (audit
|
||||
// 2026-10-12, M1). Without a manifest it is an ordinary start.
|
||||
func openData(cfg config, resync func(*store.Store) error) (st *store.Store, restored bool, err error) {
|
||||
restored, err = backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
st, err = store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
if err := resync(st); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
if err := backup.ConsumeRestore(cfg.manifestPath); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
return st, restored, nil
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all
|
||||
// roles; a role error cancels the others and is returned so the process exits
|
||||
@@ -251,37 +336,27 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
// Image and processes).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
// postfix-config.sh has written main.cf before supervisord started the
|
||||
// panel (build/entrypoint.sh), and nothing rewrites it after.
|
||||
cfg.tlsServedCertFile = health.ServedCertPath()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||
|
||||
// Restore version guard (architecture.md § Persistence): if a backup was
|
||||
// extracted into /data, its manifest version must match this binary before we
|
||||
// touch the database, so schema/format skew between versions cannot corrupt
|
||||
// the restored state. A match consumes the manifest; its absence is the
|
||||
// normal (non-restore) case.
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One database handle shared by every role. The store serialises writes
|
||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||
// can all use it without stepping on each other under WAL.
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
st, restored, err := openData(cfg, func(st *store.Store) error {
|
||||
return resyncAfterRestore(cfg, st, false)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if cfg.dmarcEnabled {
|
||||
if !restored && cfg.dmarcEnabled {
|
||||
ms := newMailStack(cfg, st)
|
||||
ms.pf.SetReloadHook(func() error { return nil })
|
||||
if err := ms.DMARC.Resync(); err != nil {
|
||||
@@ -289,12 +364,46 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// The filter lists as files, on every start: the spam filter's
|
||||
// configuration names all six, and a data directory from before the lists
|
||||
// existed has none. Not fatal - the lists are the filter's business, and
|
||||
// the panel has mail to relay either way.
|
||||
if cfg.inboundEnabled && !restored {
|
||||
if err := inbound.NewLists(st, cfg.antispamDir).Sync(); err != nil {
|
||||
log.Printf("filter lists bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
// The key the filter signs with has to be in the shared directory before
|
||||
// the filter reads it, whether or not a filter is attached yet.
|
||||
if cfg.inboundEnabled {
|
||||
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("filter key bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
||||
}
|
||||
// With the inbound relay on, the tailer also writes the inbound journal's
|
||||
// rows for refused mail and prunes that journal.
|
||||
var inboundTail *logtail.Inbound
|
||||
if cfg.inboundEnabled {
|
||||
if err := st.EnsureInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap inbound journal retention: %w", err)
|
||||
}
|
||||
inboundTail = &logtail.Inbound{Store: st, Retention: func() int {
|
||||
days, err := st.GetInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays)
|
||||
if err != nil {
|
||||
log.Printf("inbound journal retention: %v", err)
|
||||
return cfg.inboundSpamLogRetentionDays
|
||||
}
|
||||
return days
|
||||
}}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errc := make(chan error, 4)
|
||||
// Room for every role to report a failure without blocking: seven at most.
|
||||
errc := make(chan error, 7)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
@@ -302,6 +411,7 @@ func run() error {
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
|
||||
{"log-tailer", func(ctx context.Context) error {
|
||||
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||
@@ -310,12 +420,20 @@ func run() error {
|
||||
return cfg.retentionDays
|
||||
}
|
||||
return days
|
||||
})
|
||||
}, inboundTail, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
|
||||
}},
|
||||
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||
return nil
|
||||
}},
|
||||
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
|
||||
}
|
||||
|
||||
if cfg.inboundEnabled {
|
||||
roles = append(roles, struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{"inbound-journal-milter", func(ctx context.Context) error { return serveInboundJournal(ctx, cfg, st) }})
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runNotifier writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications), until ctx is cancelled. It reads what the
|
||||
// pages read - a domain's published records against the key this server signs
|
||||
// with, the certificate file, Postfix's queue - with a DNS checker of its own,
|
||||
// so that its hourly look is never a page's cached one.
|
||||
func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ms := newMailStack(cfg, st)
|
||||
dns := dnscheck.New(cfg.dnsResolvers)
|
||||
|
||||
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
|
||||
// first start of a version that signs the server's own name they do not
|
||||
// have its line yet. A failure here costs the signature, not the message:
|
||||
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, mlfi_eoh(): with no
|
||||
// SigningTable match and Mode s (build/opendkim.conf) the message is
|
||||
// accepted unsigned. A line whose key the KeyTable lacks or that does not
|
||||
// load is another matter - dkimf_apply_signtable() < 0 there answers
|
||||
// SMFIS_TEMPFAIL (assumptions A-47).
|
||||
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
|
||||
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
|
||||
} else if rebuilt {
|
||||
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
|
||||
}
|
||||
|
||||
n := notify.New(st, notify.Checks{
|
||||
Domain: func(d store.Domain) (dnscheck.Domain, error) {
|
||||
rec, err := ms.Domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
return dnscheck.Domain{}, err
|
||||
}
|
||||
// The server's own addresses are cached for a minute: one lookup
|
||||
// for a pass over all the domains.
|
||||
srv := dns.Server(cfg.hostname, false)
|
||||
return dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: rec.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: d.DMARCRua,
|
||||
}, true), nil
|
||||
},
|
||||
Certificate: func() health.Certificate {
|
||||
return health.CheckServedCertificate(cfg.tlsServedCertFile, cfg.tlsCertFile, cfg.tlsReloadInterval, time.Now())
|
||||
},
|
||||
Queue: postfix.QueueMessages,
|
||||
}, notify.NewMailer(cfg.hostname), cfg.hostname)
|
||||
return n.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bare address is one host however it is written; an IPv4-mapped one in
|
||||
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
|
||||
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entry string
|
||||
trusts []string
|
||||
refuses []string
|
||||
}{
|
||||
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
|
||||
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
|
||||
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
|
||||
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
|
||||
} {
|
||||
nets := parseTrustedProxies(c.entry)
|
||||
if len(nets) != 1 {
|
||||
t.Fatalf("%q parsed as %v", c.entry, nets)
|
||||
}
|
||||
for _, ip := range c.trusts {
|
||||
if !nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range c.refuses {
|
||||
if nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// A restore that goes wrong half way must leave the operator where they
|
||||
// started: the archive is refused before the database is opened, and a start
|
||||
// that fails after the guard is a restore again the next time (audit
|
||||
// 2026-10-12, M1 and stage 3).
|
||||
|
||||
// A start that fails after the guard - here its Resync - leaves the manifest,
|
||||
// so the next start is a restore again and runs the Resync the failed one did
|
||||
// not finish. The failed start had the database open, so its files are no
|
||||
// longer the archive's; the next start does not compare them again.
|
||||
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
broken := errors.New("postfix reload failed")
|
||||
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
|
||||
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Fatalf("the failed start consumed the manifest: %v", err)
|
||||
}
|
||||
|
||||
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
|
||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||
t.Fatalf("write stale map: %v", err)
|
||||
}
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the start after a failed one: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if !restored {
|
||||
t.Fatal("the start after a failed one did not take the data directory for a restore")
|
||||
}
|
||||
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
|
||||
t.Errorf("sender map = %q: the second start did not run the Resync", got)
|
||||
}
|
||||
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
|
||||
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, restored, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("an ordinary start ran the restore Resync")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the ordinary start after the restore: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if restored {
|
||||
t.Error("the start after a finished restore took it for a restore again")
|
||||
}
|
||||
}
|
||||
|
||||
// An archive unpacked in part - a download cut short, a tar stopped by a
|
||||
// damaged block - is refused before the database is opened, with a message
|
||||
// that says so, and nothing in the directory is changed: the database the
|
||||
// archive left is as it left it, and the manifest is there for the next try.
|
||||
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
|
||||
archive := archiveOf(t, seedPanelProject(t))
|
||||
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cut string
|
||||
at int64
|
||||
}{
|
||||
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
|
||||
{"before the deploy files", backup.ComposeFileName, 0},
|
||||
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extractCut(t, archive, target, c.cut, c.at)
|
||||
cfg := panelConfig(t, target)
|
||||
before, err := os.ReadFile(cfg.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("the cut archive left no database: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
|
||||
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
|
||||
}
|
||||
|
||||
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
|
||||
t.Error("the refused start changed the database")
|
||||
}
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
|
||||
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Errorf("the refused start removed the manifest: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A file the archive holds and the data directory holds otherwise - damaged
|
||||
// on the way, or unpacked from another archive - is refused by its name.
|
||||
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
key := filepath.Join(cfg.dataDir, "opendkim", "keys", restoreDomain, "selfpost.private")
|
||||
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open key: %v", err)
|
||||
}
|
||||
f.WriteString("x")
|
||||
f.Close()
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private"
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// archiveOf takes a full backup of a seeded project for this binary's version.
|
||||
func archiveOf(t *testing.T, deployRoot string) []byte {
|
||||
t.Helper()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
var archive bytes.Buffer
|
||||
if err := backup.Create(&archive, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||
Version: buildinfo.Version,
|
||||
DeployRoot: deployRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
return archive.Bytes()
|
||||
}
|
||||
|
||||
// entrySize is the size of the archive's entry name.
|
||||
func entrySize(t *testing.T, archive []byte, name string) int64 {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
t.Fatalf("the archive has no %s", name)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the archive: %v", err)
|
||||
}
|
||||
if hdr.Name == name {
|
||||
return hdr.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-27
@@ -27,7 +27,8 @@ import (
|
||||
// below stubs that story out — the archive is downloaded from a running panel
|
||||
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||
// is started on the result through the same startup sequence run() uses:
|
||||
// CheckRestore, store.Open, newPanel, Start.
|
||||
// openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
|
||||
// Start.
|
||||
|
||||
const (
|
||||
restorePassword = "correct-horse-battery"
|
||||
@@ -171,22 +172,17 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckRestore: %v", err)
|
||||
}
|
||||
if !restored {
|
||||
t.Fatal("CheckRestore did not report a restore")
|
||||
}
|
||||
// A record of no files: this test is about the maps, and the files it
|
||||
// seeded were never in an archive.
|
||||
writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
|
||||
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
t.Fatalf("open the restored data: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||
t.Fatalf("resync after restore: %v", err)
|
||||
if !restored {
|
||||
t.Fatal("the start did not take the data directory for a restore")
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(mapPath)
|
||||
@@ -326,28 +322,17 @@ func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||
t.Helper()
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
if restored {
|
||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||
}
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open the restored database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
if restored {
|
||||
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||
t.Fatalf("resync after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
panel, err := newPanel(cfg, st)
|
||||
if err != nil {
|
||||
t.Fatalf("build the panel: %v", err)
|
||||
@@ -417,6 +402,15 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||
// directory before the image is started.
|
||||
func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Helper()
|
||||
extractCut(t, archive, dir, "", 0)
|
||||
}
|
||||
|
||||
// extractCut unpacks the archive as extract does, except that the stream ends
|
||||
// cutAt bytes into the entry named cut: that entry is written as far as the
|
||||
// stream went and nothing after it is, which is what tar leaves of a download
|
||||
// cut short or a damaged block. An empty cut unpacks the whole archive.
|
||||
func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
@@ -426,6 +420,9 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
if cut != "" {
|
||||
t.Fatalf("the archive has no entry %s to cut", cut)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
@@ -436,6 +433,10 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
var body io.Reader = tr
|
||||
if hdr.Name == cut {
|
||||
body = io.LimitReader(tr, cutAt)
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
@@ -450,7 +451,7 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
@@ -458,6 +459,28 @@ func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if hdr.Name == cut {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testResync is the Resync a restore start runs, without reloading daemons
|
||||
// the test does not have.
|
||||
func testResync(cfg config) func(*store.Store) error {
|
||||
return func(st *store.Store) error { return resyncAfterRestore(cfg, st, true) }
|
||||
}
|
||||
|
||||
// writeContents writes the record of an archive's files into dataDir, as the
|
||||
// archive's last entry would have.
|
||||
func writeContents(t *testing.T, dataDir string, c backup.Contents) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal contents: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dataDir, backup.ContentsName), body, 0o600); err != nil {
|
||||
t.Fatalf("write contents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// aliveEvery is how often the sender check marks itself alive. The gap a
|
||||
// restart finds since the last mark is the check's downtime, so the mark's
|
||||
// age is the error of that figure.
|
||||
const aliveEvery = 2 * time.Second
|
||||
|
||||
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
|
||||
// socket of its own. Postfix attaches that socket strictly
|
||||
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.10.13,
|
||||
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
|
||||
// try again later"), so while this role is not serving, mail is deferred:
|
||||
// what that costs is counted here and
|
||||
// by the log tailer, and shown on Server > Health
|
||||
// (docs/plans/from-header-binding.md § Statistics).
|
||||
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.senderSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("sender-check listening on %s", cfg.senderSocket)
|
||||
|
||||
// Statistics are the database's business and supervisord's, and the check
|
||||
// is neither: they are kept beside it, and nothing of them is waited for
|
||||
// before it serves.
|
||||
now := time.Now()
|
||||
mark := cfg.senderSocket + ".alive"
|
||||
// The mark's age is read before the mark is kept again: asking supervisord
|
||||
// may take half a minute, and the mark must not go stale meanwhile, or a
|
||||
// restart in that half minute would count it as downtime.
|
||||
var markAt time.Time
|
||||
if fi, err := os.Stat(mark); err == nil {
|
||||
markAt = fi.ModTime()
|
||||
}
|
||||
go keepAlive(ctx, mark, st)
|
||||
go func() {
|
||||
if err := st.StartSenderCheckStats(now); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
if down, estimated := senderCheckDowntime(markAt, now, postfixStarted); down > 0 {
|
||||
if estimated {
|
||||
log.Printf("sender-check: was not answering for at most %s (supervisord did not say since when Postfix ran)", down.Round(time.Second))
|
||||
} else {
|
||||
log.Printf("sender-check: was not answering for %s while Postfix was running", down.Round(time.Second))
|
||||
}
|
||||
for _, part := range byDay(now.Add(-down), now) {
|
||||
if err := st.AddSenderCheckDown(part.day, part.down, estimated); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// The two tables the mail path itself goes by: whom Postfix lets send as
|
||||
// what, and what OpenDKIM signs.
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
return milter.ServeSender(ctx, ln, bind, st)
|
||||
}
|
||||
|
||||
// postfixState is what supervisord said about Postfix: since when it runs, if
|
||||
// it does; known is false when supervisord did not answer.
|
||||
type postfixState struct {
|
||||
started time.Time
|
||||
running, known bool
|
||||
}
|
||||
|
||||
// senderCheckDowntime is for how long the check was not serving before now
|
||||
// while Postfix was: the time since the mark was last set (markAt; zero
|
||||
// when there is none), or since
|
||||
// Postfix started if that was later. No mark means the container is new, and
|
||||
// a Postfix that is not running means mail was stopped for another reason -
|
||||
// a rollout, a reboot - and neither is the check's downtime. When supervisord
|
||||
// could not say, the time since the mark is taken and estimated is true: an
|
||||
// upper bound, since Postfix may not have been running all of it (audit
|
||||
// 2026-10-12, stage 2b - until then such a start counted nothing).
|
||||
func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down time.Duration, estimated bool) {
|
||||
if markAt.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
from := markAt
|
||||
p := ask()
|
||||
switch {
|
||||
case !p.known:
|
||||
estimated = true
|
||||
case !p.running:
|
||||
return 0, false
|
||||
case p.started.After(from):
|
||||
from = p.started
|
||||
}
|
||||
if d := now.Sub(from); d > 0 {
|
||||
return d, estimated
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// dayPart is the part of a stretch of downtime that fell on one UTC day.
|
||||
type dayPart struct {
|
||||
day time.Time
|
||||
down time.Duration
|
||||
}
|
||||
|
||||
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
|
||||
// so that a night's outage is not all booked on the morning it ended.
|
||||
func byDay(from, to time.Time) []dayPart {
|
||||
from, to = from.UTC(), to.UTC()
|
||||
var parts []dayPart
|
||||
for from.Before(to) {
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
if next.After(to) {
|
||||
next = to
|
||||
}
|
||||
parts = append(parts, dayPart{day: from, down: next.Sub(from)})
|
||||
from = next
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// postfixAskFor is how long postfixStarted keeps asking a supervisord that
|
||||
// does not answer - at the container's start it may not be listening yet.
|
||||
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
|
||||
|
||||
// postfixStarted asks supervisord since when Postfix has been running, again
|
||||
// and again for postfixAskFor while it does not answer. supervisor 4.2.5,
|
||||
// supervisor/rpcinterface.py, _interpretProcessInfo(): the uptime runs from
|
||||
// the spawn of program "postfix", which is build/postfix-wrapper.sh; it waits
|
||||
// for the milter sockets (up to MILTER_WAIT_TIMEOUT) before it execs
|
||||
// "postfix start-fg", so the time given is up to that much early
|
||||
// (assumptions A-46). For startsecs - that wait plus ten seconds
|
||||
// (build/supervisord.conf) - after its start the program is STARTING, not
|
||||
// RUNNING, and is taken here as not running: a panel restart within that
|
||||
// time after Postfix's own counts no downtime.
|
||||
func postfixStarted() postfixState {
|
||||
deadline := time.Now().Add(postfixAskFor)
|
||||
for {
|
||||
procs, err := health.Processes()
|
||||
if err == nil {
|
||||
for _, p := range procs {
|
||||
if p.Name != "postfix" || p.State != "RUNNING" {
|
||||
continue
|
||||
}
|
||||
if up, ok := health.Uptime(p.Detail); ok {
|
||||
return postfixState{started: time.Now().Add(-up), running: true, known: true}
|
||||
}
|
||||
}
|
||||
return postfixState{known: true}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Said aloud: this start's downtime is then an estimate.
|
||||
log.Printf("sender-check: statistics: when Postfix started is not known: %v", err)
|
||||
return postfixState{}
|
||||
}
|
||||
time.Sleep(postfixAskEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive sets the mark until ctx ends, and once a day drops the days of
|
||||
// the statistics that are older than a year.
|
||||
func keepAlive(ctx context.Context, path string, st *store.Store) {
|
||||
touch := func() {
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(path, now, now); err != nil {
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
log.Printf("sender-check: alive mark: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
touch()
|
||||
tick := time.NewTicker(aliveEvery)
|
||||
defer tick.Stop()
|
||||
prune := time.NewTicker(24 * time.Hour)
|
||||
defer prune.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
touch()
|
||||
case <-prune.C:
|
||||
if err := st.PruneSenderCheckDays(time.Now()); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The check's downtime is the time since its last mark, under a Postfix that
|
||||
// was running: a new container has no mark, and a Postfix that is not up, or
|
||||
// came up later than the mark, was not kept waiting by the check for all of
|
||||
// that time.
|
||||
func TestSenderCheckDowntime(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC)
|
||||
var mark time.Time // the alive mark's time; zero is no mark
|
||||
set := func(at time.Time) { mark = at }
|
||||
postfix := func(started time.Time, running bool) func() postfixState {
|
||||
return func() postfixState { return postfixState{started: started, running: running, known: true} }
|
||||
}
|
||||
silent := func() postfixState { return postfixState{} }
|
||||
down := func(ask func() postfixState) (time.Duration, bool) { return senderCheckDowntime(mark, now, ask) }
|
||||
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("no mark: %s", d)
|
||||
}
|
||||
set(now.Add(-40 * time.Second))
|
||||
if d, est := down(postfix(now.Add(-time.Hour), true)); d != 40*time.Second || est {
|
||||
t.Errorf("Postfix up throughout: %s %v, want 40s, not estimated", d, est)
|
||||
}
|
||||
if d, _ := down(postfix(now.Add(-10*time.Second), true)); d != 10*time.Second {
|
||||
t.Errorf("Postfix started within the gap: %s, want 10s", d)
|
||||
}
|
||||
if d, _ := down(postfix(time.Time{}, false)); d != 0 {
|
||||
t.Errorf("Postfix not running: %s", d)
|
||||
}
|
||||
// supervisord did not answer: the time since the mark, an estimate.
|
||||
if d, est := down(silent); d != 40*time.Second || !est {
|
||||
t.Errorf("supervisord silent: %s %v, want 40s, estimated", d, est)
|
||||
}
|
||||
set(now.Add(time.Minute)) // a clock set back
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("a mark in the future: %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Downtime is booked on the days it fell on.
|
||||
func TestByDay(t *testing.T) {
|
||||
at := func(d, h, m int) time.Time { return time.Date(2026, 10, d, h, m, 0, 0, time.UTC) }
|
||||
got := byDay(at(12, 23, 50), at(14, 0, 10))
|
||||
want := []dayPart{{at(12, 23, 50), 10 * time.Minute}, {at(13, 0, 0), 24 * time.Hour}, {at(14, 0, 0), 10 * time.Minute}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parts = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].day.Equal(want[i].day) || got[i].down != want[i].down {
|
||||
t.Errorf("part %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if one := byDay(at(12, 10, 0), at(12, 10, 0).Add(40*time.Second)); len(one) != 1 || one[0].down != 40*time.Second {
|
||||
t.Errorf("within a day: %+v", one)
|
||||
}
|
||||
if none := byDay(at(12, 10, 0), at(12, 10, 0)); len(none) != 0 {
|
||||
t.Errorf("no time: %+v", none)
|
||||
}
|
||||
}
|
||||
+83
-13
@@ -21,16 +21,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// passwordEnv names the environment variable holding the encryption password.
|
||||
@@ -70,13 +75,20 @@ func run(outPath, password string) error {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
// The floor the panel holds the same files to: an encrypted backup is
|
||||
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
|
||||
// empty password is no encryption, by design.
|
||||
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
|
||||
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
|
||||
}
|
||||
|
||||
w, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
defer w.abort()
|
||||
|
||||
sink := w
|
||||
var sink io.Writer = w
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
@@ -102,6 +114,9 @@ func run(outPath, password string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
kind := "plain"
|
||||
if password != "" {
|
||||
@@ -133,17 +148,28 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The first chunk is opened before anything is written: a wrong password
|
||||
// is told before the output is touched, and the envelope's type is
|
||||
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
|
||||
// 2026-10-12, L1).
|
||||
plain := bufio.NewReaderSize(env, 64<<10)
|
||||
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
if env.Type() != secretfile.TypeFullBackup {
|
||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||
}
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
w, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
defer w.abort()
|
||||
|
||||
if _, err := io.Copy(w, env); err != nil {
|
||||
if _, err := io.Copy(w, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
@@ -152,17 +178,61 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||
// output is where the CLI writes: stdout, or a file that takes its name only
|
||||
// once it is whole. A backup or a decryption that fails half way - a wrong
|
||||
// password, an envelope cut short - leaves whatever had the name before, and
|
||||
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
|
||||
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
|
||||
// backups are secret enough to keep off other users' eyes.
|
||||
type output struct {
|
||||
io.Writer
|
||||
tmp *os.File // nil for stdout
|
||||
path string
|
||||
}
|
||||
|
||||
func openOutput(outPath string) (*output, error) {
|
||||
if outPath == "" {
|
||||
return os.Stdout, func() {}, nil
|
||||
return &output{Writer: os.Stdout}, nil
|
||||
}
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
return &output{Writer: f, tmp: f, path: outPath}, nil
|
||||
}
|
||||
|
||||
// commit puts the whole file under its name.
|
||||
func (o *output) commit() error {
|
||||
if o.tmp == nil {
|
||||
return nil
|
||||
}
|
||||
f := o.tmp
|
||||
o.tmp = nil
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(f.Name(), o.path); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
_ = atomicfile.SyncDir(filepath.Dir(o.path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// abort removes a file that was not committed; after commit it does nothing.
|
||||
func (o *output) abort() {
|
||||
if o.tmp == nil {
|
||||
return
|
||||
}
|
||||
o.tmp.Close()
|
||||
os.Remove(o.tmp.Name())
|
||||
o.tmp = nil
|
||||
}
|
||||
|
||||
// readPassword takes the password from the given file (first line) or, when no
|
||||
|
||||
@@ -162,3 +162,76 @@ func TestReadPassword(t *testing.T) {
|
||||
t.Error("-decrypt without a password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// An encrypted backup is held to the panel's password floor (audit
|
||||
// 2026-10-12, M5); a refused one writes nothing.
|
||||
func TestEncryptionPasswordFloor(t *testing.T) {
|
||||
seedProject(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.spbk")
|
||||
err := run(out, "eleven-char")
|
||||
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
|
||||
t.Fatalf("an 11-character password = %v, want refused", err)
|
||||
}
|
||||
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the refused backup wrote %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
|
||||
// 2026-10-12, M3).
|
||||
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
|
||||
_, dataDir := seedProject(t)
|
||||
missing := filepath.Join(dataDir, "typo.db")
|
||||
t.Setenv("SELFPOST_DB_PATH", missing)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err == nil {
|
||||
t.Fatal("a backup of a database that is not there was made")
|
||||
}
|
||||
for _, p := range []string{missing, out} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -decrypt -o writes its file whole or not at all: a wrong password or an
|
||||
// envelope cut short leaves the file that had the name before, and nothing
|
||||
// else in its directory (audit 2026-10-12, L1 and stage 3).
|
||||
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
|
||||
seedProject(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
const password = "a long enough password"
|
||||
if err := run(encrypted, password); err != nil {
|
||||
t.Fatalf("create encrypted backup: %v", err)
|
||||
}
|
||||
sealed, err := os.ReadFile(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := filepath.Join(dir, "cut.spbk")
|
||||
writeFile(t, cut, string(sealed[:len(sealed)-100]))
|
||||
|
||||
outDir := t.TempDir()
|
||||
out := filepath.Join(outDir, "backup.tar.gz")
|
||||
const before = "the archive that was here"
|
||||
for _, c := range []struct{ name, in, password, want string }{
|
||||
{"wrong password", encrypted, "the wrong password", "wrong password"},
|
||||
{"cut short", cut, password, "corrupted"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
writeFile(t, out, before)
|
||||
err := runDecrypt(c.in, out, c.password)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
|
||||
}
|
||||
if got, _ := os.ReadFile(out); string(got) != before {
|
||||
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
|
||||
}
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("the failed decryption left files beside its output: %v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ DMARC_REPORTS_ENABLE=false
|
||||
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||
# INBOUND_ANTISPAM_MILTER_ACTION=accept
|
||||
|
||||
# Days the inbound journal keeps a row (7-365) until the panel says otherwise;
|
||||
# it never holds more than 10 000 rows.
|
||||
# INBOUND_SPAM_LOG_RETENTION_DAYS=90
|
||||
|
||||
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
|
||||
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
|
||||
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
|
||||
#
|
||||
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
|
||||
# deliberately. Default milter action is fail-open (accept) so a down sidecar
|
||||
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
|
||||
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) -
|
||||
# and it is the build the comments in antispam/rspamd/ were read against;
|
||||
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
|
||||
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||
# defer instead.
|
||||
|
||||
@@ -24,6 +26,17 @@ services:
|
||||
- antispam
|
||||
|
||||
antispam:
|
||||
image: rspamd/rspamd:3.11
|
||||
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
|
||||
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
|
||||
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
|
||||
# (its Dockerfile, USER 11333:11333).
|
||||
image: rspamd/rspamd:3.11.1-3
|
||||
restart: unless-stopped
|
||||
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||
volumes:
|
||||
# The verdict headers SelfPost's inbound journal reads.
|
||||
- ./antispam/rspamd/milter_headers.conf:/etc/rspamd/local.d/milter_headers.conf:ro
|
||||
# The allow and deny lists kept in the panel: the rules, and the files
|
||||
# SelfPost writes them to.
|
||||
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro
|
||||
- ./data/antispam:/etc/rspamd/selfpost:ro
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/milter_headers.conf.
|
||||
#
|
||||
# rspamd writes its verdict into two headers of every message it lets through:
|
||||
# the action it chose and the score with the symbols behind it. SelfPost's
|
||||
# inbound journal reads them (Inbound > Log shows them next to the message),
|
||||
# and the upstream mailbox server receives them with the mail.
|
||||
#
|
||||
# rspamd removes headers of these names that a sender wrote himself before it
|
||||
# adds its own - for every client, because of the two lines below. Without
|
||||
# them it would do neither for a client it counts as local or one that
|
||||
# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua,
|
||||
# skip_wanted() - skip_local and skip_authenticated are on by default, and
|
||||
# "local" is loopback, a unix socket, or an address in local_addrs, the
|
||||
# private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The
|
||||
# sender's own verdict would then pass untouched while the signature below,
|
||||
# a custom routine that skip_wanted() never consults, signed it (assumptions
|
||||
# C-R7). A private client address is ordinary here: a LAN sender, or every
|
||||
# sender when Docker's proxy relays port 25 from the bridge gateway.
|
||||
# Tried on 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 (rspamc --ucl, a
|
||||
# message carrying its own X-Spamd-Result and X-Rspamd-Action): without the
|
||||
# two lines, from 10.0.0.5 or as an authenticated user, the milter reply
|
||||
# removed neither header, added neither, and added the signature; with
|
||||
# them, both removed and rspamd's own added, as from a public address.
|
||||
#
|
||||
# Without this file the journal still lists accepted mail, with no verdict
|
||||
# next to it.
|
||||
#
|
||||
# The third header is the proof that the other two are rspamd's. The filter is
|
||||
# attached so that mail still flows when it is down, and then nothing removes
|
||||
# what a sender wrote: he could send his own verdict. So rspamd signs each
|
||||
# message - an HMAC-SHA256 of the message's queue id under a key SelfPost
|
||||
# generates and leaves in the directory it shares with this container - and
|
||||
# SelfPost reads a verdict only next to a signature it can recompute. The
|
||||
# signature travels on with the mail and is of no use to whoever sees it: it
|
||||
# fits that one queue id.
|
||||
#
|
||||
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
|
||||
# as function(task, common_meta) and returns error, headers to add, headers to
|
||||
# remove, metadata; a remove value of 0 removes every header of that name
|
||||
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
|
||||
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
|
||||
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
|
||||
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
|
||||
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
|
||||
# back in src/libserver/protocol.c), which Postfix sends at
|
||||
# end-of-message (Postfix 3.10.13, src/global/mail_params.h:3555,
|
||||
# DEF_MILT_EOD_MACROS "i").
|
||||
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||
skip_local = false;
|
||||
skip_authenticated = false;
|
||||
|
||||
custom {
|
||||
selfpost-signature = <<EOD
|
||||
return function(task, common_meta)
|
||||
local remove = { ['X-SelfPost-Filter'] = 0 }
|
||||
-- The key is read for every message and kept nowhere: SelfPost may make a
|
||||
-- new one (the file was lost, a backup was restored), and a key remembered
|
||||
-- here would go on signing what SelfPost no longer accepts until someone
|
||||
-- restarted this container. The file is one short line.
|
||||
local key
|
||||
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
|
||||
if f then
|
||||
key = f:read('*l')
|
||||
f:close()
|
||||
end
|
||||
local qid = task:get_queue_id()
|
||||
if not key or key == '' or not qid or qid == '' then
|
||||
return nil, {}, remove, {}
|
||||
end
|
||||
local hash = require "rspamd_cryptobox_hash"
|
||||
local sig = hash.create_specific_keyed(key, 'sha256', qid):hex()
|
||||
return nil, { ['X-SelfPost-Filter'] = sig }, remove, {}
|
||||
end
|
||||
EOD;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/multimap.conf.
|
||||
#
|
||||
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
|
||||
# writes them as six files under data/antispam/, which the fragment mounts at
|
||||
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
|
||||
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
|
||||
# ev_stat - inotify where the kernel has it, else a stat poll every
|
||||
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
|
||||
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
|
||||
# internal/inbound writes with.)
|
||||
#
|
||||
# Deny wins over allow, whichever entries match. A denied sender is refused
|
||||
# whatever else is found (the rule's action is a pre-result, below). An
|
||||
# allowed one is not accepted outright - rspamd would then let whichever of
|
||||
# the two rules ran first decide - but given a weight no spam score
|
||||
# outweighs, which a refusal by the deny list still overrides. (rspamd
|
||||
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
|
||||
# "normal" symbol, one with an action with the "passthrough" augmentation;
|
||||
# no "prefilter" setting is read. Measured with this file in a throwaway
|
||||
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
|
||||
# matching one message - address, domain, IP - came out reject, and a
|
||||
# changed map file was in use about ten seconds later.)
|
||||
#
|
||||
# A sender rule looks at the envelope sender only, extract_from = "smtp":
|
||||
# by default multimap.lua takes the header From: when the envelope sender is
|
||||
# empty (the "from" callback, has_from('smtp') then has_from('mime')), so a
|
||||
# bounce would be judged by an address the panel never counts it under
|
||||
# (assumptions C-R4). Tried on 2026-10-14 in rspamd/rspamd:3.11.1-3: a
|
||||
# bounce whose From: domain is on the deny list was refused by default and
|
||||
# is not with this setting; a sender on the list is refused either way.
|
||||
#
|
||||
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
|
||||
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
|
||||
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
|
||||
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
|
||||
#
|
||||
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
|
||||
# with the rule's action and message, and that action stands whatever the score
|
||||
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
|
||||
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
|
||||
# puts it into messages.smtp_message, src/libserver/milter.c,
|
||||
# rspamd_milter_send_task_results(), replies with it.
|
||||
#
|
||||
# SelfPost counts how often an entry decided a message by what these rules
|
||||
# leave behind: the deny message below and the names of the allow symbols.
|
||||
# Change either and Inbound > Filter lists shows "never" for every entry.
|
||||
|
||||
SELFPOST_DENY_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/deny_ip.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Client address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/deny_from.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/deny_domain.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender domain on the deny list";
|
||||
}
|
||||
|
||||
SELFPOST_ALLOW_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/allow_ip.map";
|
||||
score = -100.0;
|
||||
description = "Client address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/allow_from.map";
|
||||
score = -100.0;
|
||||
description = "Sender address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/allow_domain.map";
|
||||
score = -100.0;
|
||||
description = "Sender domain on the allow list";
|
||||
}
|
||||
@@ -7,11 +7,13 @@
|
||||
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
||||
# redirect vhost, and renews in the background via its own timer. The resulting
|
||||
# PEM files land at
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
|
||||
# bind-mount that directory read-only into the SelfPost container (see
|
||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||
# can present the very same certificate on 465/587. One certificate, two
|
||||
# consumers — no separate cert just for the panel.
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
|
||||
# as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
|
||||
# to them and mount /etc/letsencrypt read-only at the same path (see
|
||||
# ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
|
||||
# present the very same certificate on 465/587. Mounting live/mail.example.com
|
||||
# alone gives the container links into a directory it does not have. One
|
||||
# certificate, two consumers — no separate cert just for the panel.
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName mail.example.com
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
# storage detail, not a documented public API — VERIFY the exact path against
|
||||
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||
# before wiring it into the volume mount below.
|
||||
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
|
||||
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
|
||||
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
|
||||
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
|
||||
# second issuer only when an email is configured, and the Caddyfile below sets
|
||||
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# on the HOST (the target audience for this project typically runs Apache there
|
||||
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
||||
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
||||
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
|
||||
# docs/guide.md "Reverse proxy (mandatory)" for the certbot steps that produce the PEM files
|
||||
# this compose file mounts.
|
||||
#
|
||||
# Usage:
|
||||
@@ -18,10 +18,12 @@
|
||||
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
||||
# version against the running binary's version, so ":latest" would make that
|
||||
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
||||
# The 2.x line is not on ghcr.io yet: build it locally under this exact name
|
||||
# (README, Quick start) and Compose will use it without pulling.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.0.0
|
||||
image: ghcr.io/mixeme/selfpost:2.7.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
@@ -47,6 +49,7 @@ services:
|
||||
# How long the send log keeps rows before the background sweep deletes
|
||||
# them — the main driver of /data growth over time.
|
||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||
INBOUND_SPAM_LOG_RETENTION_DAYS: "${INBOUND_SPAM_LOG_RETENTION_DAYS:-90}"
|
||||
# Sliding idle timeout for the panel login session, in days.
|
||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||
@@ -69,9 +72,15 @@ services:
|
||||
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||
- .:/selfpost-deploy:ro
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them.
|
||||
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
|
||||
# /etc/letsencrypt on the host.
|
||||
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
|
||||
# links to where your reverse proxy's certbot keeps them.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
# With certbot on the host (docs/guide.md, "Reverse proxy (mandatory)", Apache):
|
||||
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
|
||||
# files are themselves relative links into archive/ - so the whole
|
||||
# directory is mounted, at the same path, for the links to lead
|
||||
# somewhere inside the container. Uncomment it then.
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
ports:
|
||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||
@@ -81,7 +90,7 @@ services:
|
||||
# reachable from the internet without TLS.
|
||||
- "465:465"
|
||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||
# when the variable is true — see docs/guide.md "Published ports".
|
||||
# when the variable is true — see docs/guide.md "Ports".
|
||||
- "587:587"
|
||||
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||
# when the variable is true.
|
||||
@@ -113,6 +122,13 @@ services:
|
||||
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||
# self-healing; without them chmod fails with EPERM and the container
|
||||
# crash-loops on start.
|
||||
# Each capability above as capabilities(7) defines it (man7.org,
|
||||
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
|
||||
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
|
||||
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
|
||||
# no-new-privileges only changes execve() (Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst): root may still call
|
||||
# setuid()/setgid() with these capabilities.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -8,14 +8,21 @@
|
||||
# takes over 80/443 and proxies to the panel over the compose network
|
||||
# instead). Merge it with the base file — run this from the deploy/ directory
|
||||
# (Compose resolves every relative path in both files against the directory of
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
#
|
||||
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||
# serve — run once):
|
||||
# First-run certificate issuance, before nginx has a certificate to serve -
|
||||
# run once, with port 80 free (certbot answers the challenge itself;
|
||||
# --webroot would need nginx already serving /.well-known). --entrypoint is
|
||||
# needed: the service's own entrypoint is the renewal loop below, an
|
||||
# `sh -c` that ignores any arguments given to `run`
|
||||
# (docs.docker.com/reference/compose-file/services/#entrypoint; assumptions
|
||||
# D-guide:301):
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
# run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
# run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
|
||||
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
|
||||
services:
|
||||
@@ -29,10 +36,13 @@ services:
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Same host directory certbot below writes into — plain bind mount, no
|
||||
# named volume, so the PEM files are as directly inspectable as in the
|
||||
# Apache scenario.
|
||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||
# certs/ holds two links, fullchain.pem and privkey.pem, to
|
||||
# /etc/letsencrypt/live/mail.example.com/ - a path inside the
|
||||
# container: certbot's directory below is mounted there whole, because
|
||||
# its live/ files are relative links into archive/ (mounting live/ alone
|
||||
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
@@ -54,4 +64,11 @@ services:
|
||||
- ./nginx/certbot-www:/var/www/certbot
|
||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||
# until a certificate is within its renewal window.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
|
||||
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
|
||||
# certificate near expiry.) The first certificate was issued --standalone
|
||||
# (see the top of this file), and certbot renews with the plugin it was
|
||||
# issued with "unless you specify other plugins or options" (same page,
|
||||
# "Renewing certificates") - by then nginx holds port 80 and serves the
|
||||
# webroot, so renewal names it; a renewal that succeeds with it saves it.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done'
|
||||
|
||||
@@ -17,6 +17,8 @@ server {
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
|
||||
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
|
||||
http2 on;
|
||||
server_name mail.example.com;
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
@@ -25,7 +27,10 @@ services:
|
||||
# Traefik only proxies the panel; it never sees the mail ports.
|
||||
# These live on the selfpost service (not traefik's) because the
|
||||
# docker provider reads routing labels off the container being routed
|
||||
# to, not off traefik itself.
|
||||
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
|
||||
# docker/, "Routing Configuration": "Traefik uses container labels";
|
||||
# with exposedByDefault=false only containers labelled
|
||||
# traefik.enable=true.)
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||
|
||||
Regular → Executable
+59
-12
@@ -1,28 +1,75 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
||||
# Traefik keeps its certificates there, so a PEM extraction step is required.
|
||||
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
||||
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
||||
# by SelfPost/Postfix.
|
||||
#
|
||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
set -eu
|
||||
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
#
|
||||
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
|
||||
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
|
||||
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
|
||||
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
|
||||
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
|
||||
# docker-compose.traefik.yml names.
|
||||
#
|
||||
# Nothing is replaced unless both new files are PEM: a domain acme.json does
|
||||
# not hold, an empty field or a broken file stops the script with an error and
|
||||
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
|
||||
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
|
||||
# certificate; Postfix's TLS broke at its next restart). The new files are
|
||||
# written beside the old ones, the owner's alone until they are whole, then
|
||||
# renamed into place.
|
||||
set -euo pipefail
|
||||
|
||||
ACME_JSON="${1:?path to acme.json}"
|
||||
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
umask 077
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
|
||||
die() {
|
||||
echo "extract-cert: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||
# field prints the base64 of one field of the domain's first certificate, or
|
||||
# nothing.
|
||||
field() {
|
||||
jq -r --arg domain "$DOMAIN" --arg field "$1" '
|
||||
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
|
||||
' "$ACME_JSON"
|
||||
}
|
||||
|
||||
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||
cert_b64=$(field certificate)
|
||||
key_b64=$(field key)
|
||||
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
|
||||
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
|
||||
|
||||
tmp_cert="" tmp_key=""
|
||||
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
|
||||
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
|
||||
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
|
||||
|
||||
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
|
||||
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
|
||||
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
|
||||
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
|
||||
|
||||
chmod 0640 "$tmp_cert" "$tmp_key"
|
||||
# The group the files had stays theirs: the guide gives the key to group 10998
|
||||
# so the panel can read it for a full backup, and a run from root's cron would
|
||||
# otherwise hand it to root's group (review of audit stage 5).
|
||||
for f in fullchain privkey; do
|
||||
if [ -e "$OUT_DIR/$f.pem" ]; then
|
||||
tmp=$tmp_cert
|
||||
[ "$f" = privkey ] && tmp=$tmp_key
|
||||
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
|
||||
fi
|
||||
done
|
||||
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
|
||||
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
|
||||
trap - EXIT
|
||||
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||
|
||||
+587
-78
@@ -26,22 +26,44 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
|
||||
| Program | User | Priority | Role |
|
||||
|---|---|---|---|
|
||||
| `syslog-relay` | panel | 50 | The container's `/dev/log`: passes what OpenDKIM logs through syslog(3) to the container's log (`panel -syslog-relay`) |
|
||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||
| `panel` | panel | 200 | HTTP UI + sender check + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
|
||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||
| `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it |
|
||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||
|
||||
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||
`postfix start-fg`.
|
||||
Start order: syslog-relay → OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then runs
|
||||
`postfix start-fg` and stays its parent: on TERM it runs `postfix stop`, so a
|
||||
stop of the program or of the container stops Postfix as Postfix means it to
|
||||
be stopped (master runs in a session of its own, which supervisord's signal
|
||||
never reaches). Program `postfix` has `startsecs` = `MILTER_WAIT_TIMEOUT` + 10
|
||||
(set by the entrypoint), so the wrapper giving up on the sockets, or a Postfix
|
||||
that will not start, is an exit supervisord counts as too quick: once
|
||||
`startretries` (3) are spent, the program is FATAL. For those seconds after
|
||||
each start it is STARTING.
|
||||
|
||||
`crashexit` event listener exits the container on any managed process FATAL so
|
||||
Docker `restart: unless-stopped` recreates a broken instance.
|
||||
`crashexit` event listener exits the container when a program of the mail path
|
||||
is FATAL - it crashed before its `startsecs` four times running (the start
|
||||
and `startretries=3`) - or has
|
||||
exited unexpectedly five times in ten minutes, however long each run lasted
|
||||
(an exit after `startsecs` is EXITED and restarted, never FATAL; `2.6.0`), so
|
||||
Docker `restart: unless-stopped` starts it again - the same container, not a
|
||||
new one: the entrypoint clears what the last run left in `/run` (the milter
|
||||
sockets, the sender check's alive mark). Besides a stop, supervisord itself
|
||||
dying, or the entrypoint refusing a setting, nothing else exits the
|
||||
container. `logrotate`'s FATAL (a config logrotate would ignore),
|
||||
`postfix-reload`'s (reported to the page that asked), `cert-reload`'s and
|
||||
`syslog-relay`'s stay on Server › Health instead.
|
||||
The panel runs `postfix-reload` through supervisord's XML-RPC interface and
|
||||
waits for its exit status (`internal/supervisor`, `RunOnce`); the program
|
||||
rests EXITED after a run.
|
||||
|
||||
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
|
||||
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe (its
|
||||
90-second start period covers postfix's STARTING).
|
||||
|
||||
---
|
||||
|
||||
@@ -50,6 +72,7 @@ panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
```
|
||||
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||
│
|
||||
├─► sender check (From: against the login, tempfail on failure)
|
||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||
└─► outbound MX delivery (port 25 client)
|
||||
@@ -74,10 +97,76 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
`smtpd_tls_security_level=encrypt`.
|
||||
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
|
||||
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
|
||||
`smtpd_milters` is always overridden on this service (to the antispam milter
|
||||
or to nothing), because main.cf's submission chain would otherwise let a
|
||||
signing outage defer relayed mail and would file inbound mail into the
|
||||
outbound send log under the sender's domain.
|
||||
`smtpd_milters` is always overridden on this service, because main.cf's
|
||||
submission chain would otherwise let a signing outage defer relayed mail and
|
||||
would file inbound mail into the outbound send log under the sender's
|
||||
domain. With the inbound relay on the chain is the optional antispam milter,
|
||||
then the panel's **inbound journal milter** (unix socket
|
||||
`INBOUND_JOURNAL_MILTER_SOCKET`, default
|
||||
`/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC
|
||||
ingest alone it is empty. Postfix hands a message to its milters one after
|
||||
another at end-of-message, each seeing what the one before added, so the
|
||||
journal milter sees only mail the filter let through and reads the filter's
|
||||
verdict headers (`X-Rspamd-Action`, `X-Spamd-Result`) off it. It writes one
|
||||
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report
|
||||
address is not one - and never refuses anything
|
||||
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers
|
||||
are read only while a filter is attached, and only next to the filter's
|
||||
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
|
||||
message's queue id under the key in `/data/antispam/filter.key`
|
||||
(`inbound.FilterKey` makes it on first start; rspamd reads it through the
|
||||
mounted directory and signs in a custom routine of `milter_headers.conf`).
|
||||
rspamd removes a sender's own copies of the three headers and writes and
|
||||
signs its own for every message it lets through: `skip_local` and
|
||||
`skip_authenticated` are off in that file, so this holds whatever the
|
||||
sender's address (rspamd 3.11.1, `src/plugins/lua/milter_headers.lua`,
|
||||
`skip_wanted()`).
|
||||
With the filter down - it is attached fail-open - a sender's own verdict
|
||||
headers carry no signature and the row is that of mail nobody judged.
|
||||
Refused mail never reaches this milter.
|
||||
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
|
||||
(`syslog_name`), and the log tailer turns a `reject:` line of
|
||||
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
|
||||
`milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict,
|
||||
counted only for a queue id that listener opened) into a row, dated by the
|
||||
line, when the recipient is at an inbound domain
|
||||
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
|
||||
names one recipient (the last), so a message refused at end-of-message is
|
||||
one row whatever its recipient count. The tailer saves its place every five seconds,
|
||||
so after an unclean stop it reads some lines again; the lines it reads on
|
||||
start are written only if the same row is not there already. The
|
||||
journal is pruned every six hours: rows
|
||||
older than the `inbound_spam_log_retention_days` setting, then down to
|
||||
10 000.
|
||||
**Filter lists** (`inbound_spam_list`): allow and deny entries for the
|
||||
external filter - sender address, sender domain, client address or network -
|
||||
validated and normalised by `internal/inbound` (`lists.go`) and written as
|
||||
six map files under `/data/antispam/` (`ANTISPAM_DIR`), each atomically, on
|
||||
every change and on every start with the relay on. SelfPost never consults
|
||||
them. The sidecar mounts the directory and rspamd's multimap rules
|
||||
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
|
||||
read them: a deny entry's rule carries the action `reject`, which rspamd
|
||||
sets as a pre-result (a passthrough result: the remaining filters are not
|
||||
run and the action stands whatever the score), an allow entry is a symbol
|
||||
weighing -100. Allow is
|
||||
deliberately not an `accept` action: with two pre-results rspamd keeps the
|
||||
one set first, and deny then won or lost by rule order (seen on rspamd
|
||||
3.11, date not recorded). A refusal by the deny list overrides any score,
|
||||
so deny wins whichever entries match - measured in a throwaway
|
||||
`rspamd/rspamd:3.11.1-3` with this file, 2026-10-15: the same address or
|
||||
IP on both lists, an allowed address with a denied domain or IP, and an
|
||||
allowed IP with a denied address were all `reject` (assumptions
|
||||
D-arch:121). A sender entry is matched against
|
||||
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes
|
||||
a bounce's `From:` header instead - rspamd 3.11.1,
|
||||
`src/plugins/lua/multimap.lua`, the `from` callback).
|
||||
How often an entry decided a message is not stored: `Lists.Usage` reads it
|
||||
off the journal on each view of Filter lists - the rows a list decided (the
|
||||
deny rule's reply, the allow rule's symbol, which the journal milter keeps
|
||||
ahead of its six-symbol cap) matched against the entries as rspamd matches
|
||||
them. When that stops being good enough is written in
|
||||
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Filter
|
||||
list usage.
|
||||
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
|
||||
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
|
||||
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
|
||||
@@ -85,40 +174,89 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
[internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
|
||||
an empty upstream host are omitted from the maps. Optional
|
||||
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
|
||||
DMARC-only port 25); default `milter_default_action` is fail-open
|
||||
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||
DMARC-only port 25); it is attached with its own `default_action`,
|
||||
`INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set
|
||||
to `tempfail` (main.cf's `milter_default_action` is `tempfail`).
|
||||
**DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
|
||||
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
|
||||
on, one listener serves both paths; per-IP rate uses
|
||||
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
|
||||
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
|
||||
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. That holds the
|
||||
envelope sender; the `From:` header is held to the same table by the
|
||||
panel's sender check (§ Panel roles).
|
||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||
- **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** —
|
||||
`smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix
|
||||
3.10.13, `src/smtpd/smtpd.c`, `receive_data_message()`, the DATA loop at
|
||||
3713: a `.` line next to a bare `<LF>` is dropped, not taken as the end); a
|
||||
bare `<LF>` elsewhere is read as `<CR><LF>`. The submission ports keep
|
||||
Postfix's default, which is the same `normalize` since Postfix 3.9
|
||||
(`src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`); the image ran
|
||||
Postfix 3.7.11 until `2.7.0`, whose default was `no`, hence the explicit
|
||||
setting on port 25.
|
||||
- **The submission ports hide the application in `Received:`** (`2.7.0`) —
|
||||
`smtpd_hide_client_session=yes` on `smtps/inet` and `submission/inet`:
|
||||
Postfix's header reads `Received: by <hostname> (Postfix) id <queue id>`,
|
||||
with the recipient when there is one, and the date - no HELO name, client
|
||||
name or address, TLS, login or protocol (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd.c:3495` and `:3627-3645`). Port 25 keeps the full
|
||||
header, which RFC 5321 requires there (`proto/postconf.proto:19587-19589`).
|
||||
The queue id in the header leads to the message's row in the panel's log
|
||||
and to smtpd's `client=` line in mail.log.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
|
||||
reload via `cert-reload`.
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
|
||||
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
|
||||
Postfix's TLS_README advises (Postfix itself reads the key as root and does
|
||||
not check its mode), which the mount cannot give; Postfix reads the copy.
|
||||
`cert-reload` compares the mount with the copy daily; when they differ and
|
||||
the certificate and key belong together it renews the copy (each file
|
||||
renamed into place) and reloads Postfix.
|
||||
|
||||
### OpenDKIM
|
||||
|
||||
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
||||
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
||||
|
||||
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
|
||||
tables too, without being a row of the registry
|
||||
(`domain.Service.SetServerName`): the panel's notifications are sent as
|
||||
`postmaster@` that name and signed with it. No application can use it as an
|
||||
envelope sender - the sender map is built from the registry - and since
|
||||
`2.4.0` none can have a message signed as the server by writing it into
|
||||
`From:` either: the sender check holds the header against the login (§ Sender
|
||||
check). A sending domain of the same name shares its key and selector with
|
||||
the server; deleting that domain leaves the server signed under the default
|
||||
selector, which is the same key only if that was the domain's. At its start
|
||||
the notifier makes sure the tables have the line and name the selector the
|
||||
panel shows (`EnsureServerSigned`): tables are otherwise rebuilt only when a
|
||||
domain is added or deleted.
|
||||
|
||||
### Panel binary ([cmd/panel](../cmd/panel))
|
||||
|
||||
One process, four roles:
|
||||
One process, six roles (seven with the inbound relay on: its inbound journal
|
||||
milter, § Postfix above, port 25):
|
||||
|
||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
||||
proxy only. On start it runs `postconf -h -x` once for the deferred-mail retry
|
||||
parameters (`queue_run_delay`, `minimal_backoff_time`,
|
||||
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
|
||||
`delay_warning_time`) and caches the snapshot on the handlers config. The
|
||||
`delay_warning_time`), reads each as Postfix does - one number and one
|
||||
unit, the parameter's own default unit when there is none (`5d` for the
|
||||
lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at
|
||||
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.10.13,
|
||||
`src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on
|
||||
the handlers config. The
|
||||
Outbound queue box and a delivery's `deferred` / `bounced` history print those
|
||||
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
|
||||
and **strict** (`default_action=tempfail`): § Sender check below.
|
||||
3. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||
ceilings apply to every client IP; an application ceiling **overrides** the
|
||||
@@ -130,13 +268,191 @@ One process, four roles:
|
||||
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
4. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
It also keeps the lines Postfix writes under a queue id, for the message's
|
||||
page (§ Log tailer, *Stored lines*), and counts the mail Postfix deferred
|
||||
because the sender check did not answer.
|
||||
5. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
6. **notifier** — writes to the panel's users when something needs them
|
||||
(§ Notifications below).
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
Milter chain in Postfix: the sender check (tempfail), OpenDKIM (tempfail),
|
||||
then the journal (accept on failure). Mail submitted locally with
|
||||
`sendmail(1)` — the notifier's — passes OpenDKIM alone (`non_smtpd_milters`):
|
||||
it is signed, it is the panel's own and so needs no check of whose it is, and
|
||||
it is not an application's mail to be journalled or limited. Bounces Postfix
|
||||
generates pass no milter.
|
||||
|
||||
### Sender check
|
||||
|
||||
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's
|
||||
`From:` header against the application that sends it
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds
|
||||
the envelope sender against the SASL login, and OpenDKIM chooses the signing
|
||||
key by the header; until `2.4.0` nothing connected the two, and an
|
||||
application could have mail signed as another domain of the server or as the
|
||||
server itself.
|
||||
|
||||
- **The rule is the envelope's.** The check reads the file Postfix reads for
|
||||
`smtpd_sender_login_maps` and makes the same lookup: the address itself is
|
||||
bound to the login, or `@domain` is. So an application set to any address of
|
||||
its domain may write any address of that domain, and one with a list only
|
||||
the listed ones; everything else is refused - another registered domain,
|
||||
the server's name, a domain the server knows nothing of. One table for
|
||||
both senders, read as Postfix reads a texthash table and looked up as
|
||||
Postfix looks it up: the address, the address without its `+extension`
|
||||
(`recipient_delimiter`, which `build/postfix-config.sh` sets for that
|
||||
reason), then `@domain` - and the **first of these keys in the table
|
||||
decides**, its owners alone: an address listed for one application is not
|
||||
open to another that holds the whole domain. Logins are compared without
|
||||
regard to case, as Postfix compares them (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
|
||||
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
|
||||
twice makes Postfix refuse the whole table, and the check defers then too.
|
||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||
one, is refused), read with OpenDKIM's own parser: libopendkim's
|
||||
`dkim_mail_parse()`, ported line for line from Debian's opendkim
|
||||
2.11.0~beta2 (`internal/milter/mailparse.go`) and tested against the C on
|
||||
two hundred thousand lines. The check asks Postfix for header values with
|
||||
their leading space, as OpenDKIM does (`SMFIP_HDR_LEADSPC`), so both read the
|
||||
same bytes. The address it returns is the one judged - the one OpenDKIM
|
||||
signs for - whatever a display name, a comment or a quoted local part
|
||||
holds; a parse failure, no address, or a second one after it is refused,
|
||||
and so is an address over 256 bytes, which OpenDKIM would match its
|
||||
`SigningTable` against cut short (`dkimf_apply_signtable()`,
|
||||
`MAXADDRESS`). Encoded words are not decoded, by either. The line must also
|
||||
be one address in RFC 5322's grammar (`net/mail`), at the domain OpenDKIM
|
||||
signs for, so that a mail program shows the address that is signed; that
|
||||
address, its local part unquoted, is what the bindings are asked about.
|
||||
- **The verdict** comes at the end of the headers: `550 5.7.1`, permanent,
|
||||
naming the line, as a reply code of the milter's own. What the client wrote
|
||||
is reduced to printable ASCII and cut before it is said back, logged or
|
||||
stored. A message with no SASL login is refused as well; the submission
|
||||
ports do not let one this far.
|
||||
- **Strict, and therefore small.** Postfix attaches the socket with
|
||||
`default_action=tempfail`: while the panel does not answer on it within the
|
||||
milter timeouts, mail is deferred, not accepted unchecked. The role
|
||||
therefore reads no database. Its two inputs are files the panel rewrites by
|
||||
rename and the mail path itself goes by; they are read again when their
|
||||
time or size has changed, and a file that cannot be read defers mail - a
|
||||
table from before would let a deleted application go on sending. The
|
||||
send-log row of a refusal (`status=rejected`, `reason=from`,
|
||||
`header_from`) is handed to another goroutine through a buffer and is not
|
||||
waited for. It is one row for the message, naming the first recipient and
|
||||
how many more there were: a row per recipient would let one refused message
|
||||
write a thousand.
|
||||
- **What it costs is counted.** The role marks itself alive every two
|
||||
seconds (the socket's path with `.alive`, under `/run`; the entrypoint
|
||||
removes it at every container start, so a restart begins without one). At
|
||||
start the time since the mark is the check's
|
||||
downtime - counted from Postfix's own start if that was later, and not at
|
||||
all when supervisord says Postfix is not running, since mail was then
|
||||
stopped for another reason. Supervisord is asked for half a minute; when it
|
||||
does not answer, the time since the mark is taken as an upper bound and the
|
||||
day is marked estimated (`sender_check_days.estimated`), which the card
|
||||
shows as ≈. The log tailer counts the mail: Postfix
|
||||
writes a warning naming the socket and then, from the same smtpd process,
|
||||
`milter-reject: … 451 4.7.1 Service unavailable` from the connection on
|
||||
(the client is told `454 4.3.0` at `AUTH` and leaves), so what is counted
|
||||
is a connection turned away, once - how many messages it held back is not
|
||||
known: a client may bring one or several, or come back for the same one.
|
||||
The reject line does not name the milter and the journal milter's
|
||||
level-2 refusal reads the same, hence the warning. A panel that is running
|
||||
and stops answering in the middle of a message (its socket accepts, nothing
|
||||
replies) is met by cleanup, not smtpd: past `DATA` it is cleanup that talks
|
||||
to the milters, and after the content timeout it writes `warning: milter
|
||||
unix:<socket>: can't read SMFIC_HEADER reply packet header` and refuses the
|
||||
message with `4.7.1` (seen on the e2e stand with the panel frozen;
|
||||
`panel_hung_mid_message_is_counted` checks it at every run). One
|
||||
cleanup process serves message after message, so each such warning is
|
||||
counted as a message. The two are counted apart (the owner, 2026-10-14):
|
||||
connections turned away in `deferred`, messages deferred mid-message in
|
||||
`messages` of `sender_check_days`, a row per UTC day kept a year, and
|
||||
Server › Health shows today, 30 days and 12 months in a column each. The
|
||||
counts also see a panel that is running and not answering, which the mark
|
||||
does not. Downtime is cut at the UTC midnights it crossed. A log read again
|
||||
after an unclean stop does not count again: the count and the place in the
|
||||
log it reached (the file's fingerprint and the offset past the line) are
|
||||
saved in one transaction, and while the tailer catches up a warning at or
|
||||
before that place in the same file is passed over - a second warning of
|
||||
the same second is not lost; in another file the time decides.
|
||||
- **Why it is not a process of its own.** Decided with the owner: measure
|
||||
first. The role has a socket of its own so that the journal stays
|
||||
fail-open, and moving it out later changes where it is started and nothing
|
||||
else.
|
||||
|
||||
### Notifications
|
||||
|
||||
[internal/notify](../internal/notify/notify.go) reports four things to the
|
||||
users of the panel: a sending domain's DNS record that stopped matching, a
|
||||
level-2 rate limit that refused mail, the TLS certificate about to expire, a
|
||||
message more than three days in the queue. The rules of when a message goes
|
||||
out are the owner's and are written in
|
||||
[plans/panel-notifications.md](plans/panel-notifications.md) § When a message
|
||||
is sent; the operator's view is guide § Notifications.
|
||||
|
||||
- **Readings.** Once an hour, starting three minutes after the process does:
|
||||
every sending domain's records through a DNS checker of the notifier's own,
|
||||
forced past the cache (`cmd/panel/notifier.go` builds the same query a
|
||||
domain's page does); the certificate file; `postqueue -j` for the arrival
|
||||
time of each queued message. Once a minute: the send log's `rejected` rows
|
||||
of the last day, through a partial index of their own.
|
||||
- **A refused row is not believed whole.** Its domain is the one in
|
||||
`MAIL FROM`, which the client typed and Postfix has not yet held against
|
||||
the login when the milter refuses; the same status is written when an
|
||||
application is refused for its client address or its `From:` line. Since
|
||||
`2.4.0` a row says which (`send_log.reason`) and only a limit's rows are
|
||||
taken; a row from before has no reason. So refusals are counted by
|
||||
the domain the login is registered under, only for logins that exist, and
|
||||
only while a level-2 limit is set on the login or its domain
|
||||
(`Store.LimitRefusalsSince`). One subject per sending domain, whichever of
|
||||
its applications are refused.
|
||||
- **State** is a row per subject in `notify_state` (`dns:<domain>`, `cert`,
|
||||
`queue`, `limit:<domain>`), which is what makes each thing be said
|
||||
once and a restart say nothing again. A DNS subject goes *pending* on the
|
||||
first bad check and *open*, with a message, on a later one at least 45
|
||||
minutes after it - a count alone would let the last check before a restart
|
||||
and the first after it stand for "an hour apart"; back the same way. The
|
||||
certificate's row remembers which threshold was said for which expiry. A
|
||||
deleted domain's `dns:` row goes at the next check; its `limit:` row stays
|
||||
until it was last said more than 48 hours ago (`CheckLimits`).
|
||||
- **Said means sent.** A problem that arises while no user can be told, or
|
||||
while Postfix does not take the message, stays unsaid and is told at a
|
||||
later check. Its ending, with nobody left to tell, is simply over.
|
||||
- **Owed to each recipient** (since `2.5.4`, `notify_addressees`): the
|
||||
subject's first message records who it was addressed to and who was
|
||||
handed a copy; while the subject is open, every later check hands it to
|
||||
those not told yet - a copy that failed, or that the ceiling held back -
|
||||
who are still recipients. A user who comes to hear of a subject only while
|
||||
it is open is not mailed about it, and its ending goes to those who were
|
||||
told. A subject opened before `2.5.4` has no such record, and its ending
|
||||
goes to every recipient, as it did. It is owed to an address: a user who
|
||||
changes their e-mail while a subject is open is a new address, told
|
||||
neither its follow-up nor its end - accepted, a subject lasts hours to
|
||||
days.
|
||||
- **The notifier's own mail is not a subject.** A notification waiting for a
|
||||
mailbox that does not answer, or its bounce coming back to
|
||||
`postmaster@<hostname>` - which receives no mail - is left out of the
|
||||
queue check.
|
||||
- **A ceiling** of 60 messages an hour stands over all of it.
|
||||
- **No information is not a finding.** A lookup that did not answer, a queue
|
||||
that cannot be listed, a certificate that cannot be read move no state.
|
||||
- **Recipients** come from the store at the moment of sending: global users
|
||||
for everything, domain users for subjects that are a domain's; only users
|
||||
with an e-mail and their own switch on. One message per recipient, so users
|
||||
do not see each other's addresses.
|
||||
- **The instance switch** (settings key `notifications`, on unless turned
|
||||
off) stops the checks themselves, not only the mail: no state moves while
|
||||
it is off.
|
||||
- **Sending** is `sendmail -i -f postmaster@<hostname> -- <address>` as the
|
||||
unprivileged panel user, which is a member of `postdrop` and so can write
|
||||
the maildrop directory without the setgid bit `no-new-privileges` forbids.
|
||||
A message that could not be handed over leaves the state where it was and
|
||||
is tried at the next check. The text names the page to open and has no
|
||||
link: the panel does not know its own address.
|
||||
|
||||
### Log tailer and `mail.log` rotation
|
||||
|
||||
@@ -154,44 +470,197 @@ Rotation uses rename + `postfix reload`
|
||||
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
|
||||
the latter can drop `status=sent` lines and leave send-log rows stuck at
|
||||
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
|
||||
recreates the file lazily on first write as mode `0600`, which the unprivileged
|
||||
panel user cannot read). `follow()` drains the old inode once more before
|
||||
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||
an error.
|
||||
recreates the file on its first line after the reload, as root and mode
|
||||
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
|
||||
old inode once more before switching descriptors, and keeps it open and reads
|
||||
it for a minute after: postlogd keeps writing to the renamed file until the
|
||||
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
|
||||
src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing
|
||||
log file as an empty tail, not an error.
|
||||
|
||||
**Delivery lines are read at the head of the line.** The pattern that turns a
|
||||
line into a row's status (`deliveryRe`) is matched after the timestamp, the
|
||||
host name and `program[pid]:`, at the queue id - like the stored lines' and the
|
||||
inbound journal's. The tail of a line is the far end's words (a reply, a HELO
|
||||
name), and a pattern that could start there let a stranger's HELO name file a
|
||||
delivery (audit 2026-10-12, M12).
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
|
||||
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
|
||||
on start resumes from it, parsing the tail written while the panel was down. If
|
||||
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||
the current file from the start; re-parsing lines is harmless because
|
||||
`UpdateStatus` writes the same status onto the same row. Only a first-ever start,
|
||||
with nothing stored, begins at end-of-file, so installing the panel does not
|
||||
replay a pre-existing log.
|
||||
tailer stores its position plus a fingerprint of the log - the hash of its first
|
||||
512 bytes and the file's device and inode, so two logs that begin alike are two
|
||||
files and a renamed file keeps its fingerprint - and on start resumes from it,
|
||||
parsing the tail written while the panel was down. If the fingerprint no longer
|
||||
matches, the log was rotated in the meantime: the tailer first opens
|
||||
`mail.log.1` (left plain by `delaycompress`), and if that is the file the saved
|
||||
place belongs to it reads it from there to its end, then the new file from its
|
||||
start - what Postfix wrote between the stop and the rotation is not lost. A
|
||||
`mail.log.1` that is another file (rotated twice) is logged with the time from
|
||||
which nothing was read. Re-parsing lines is harmless because `UpdateStatus`
|
||||
writes the same status onto the same row. Only a first-ever start, with nothing
|
||||
stored, begins at end-of-file, so installing the panel does not replay a
|
||||
pre-existing log.
|
||||
|
||||
**A refusal's line, and a refusal the panel did not write.** A message refused
|
||||
for its `From:` line has a queue id (cleanup gives one before the headers are
|
||||
judged); the sender check writes it on the row, and the message's page shows
|
||||
the lines stored under it - only those of the minutes around the refusal, since
|
||||
Postfix frees the id at once. A message the journal milter refused at
|
||||
`MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL
|
||||
from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is
|
||||
Postfix's last field on an authenticated session (Postfix 3.10.13,
|
||||
`src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no
|
||||
application's - and puts it on the nearest refused row of that client
|
||||
address, login and sender within two minutes
|
||||
(`send_log.refusal_line`; the milter writes `send_log.client_ip`), retrying
|
||||
for 30 s when the row is not written yet; a line already on a row goes on no
|
||||
other. The sender check writes its
|
||||
row through a buffer after answering 550; a panel stopped in between wrote
|
||||
none. The tailer remembers each queue id's login from smtpd's `client=` line,
|
||||
and 30 s after cleanup's `milter-reject: END-OF-MESSAGE … 5.7.1 From:` line it
|
||||
writes the row from the line if no refused row of that id exists
|
||||
(`send_log.recovered`, shown on the page). cleanup's line names one
|
||||
recipient, the message's last (`src/cleanup/cleanup_milter.c`), so a row
|
||||
written from it has that one, which its page says. It reads from its saved offset, so
|
||||
a kill is covered as well as a clean stop.
|
||||
|
||||
**Expiry.** A message Postfix gives up on at the end of its queue lifetime has
|
||||
no line per recipient: qmgr writes one line for the message, `<id>:
|
||||
from=<...>, status=expired, returned to sender` (`force-expired` after
|
||||
`postsuper -e`; Postfix 3.10.13, `src/qmgr/qmgr_active.c`,
|
||||
`qmgr_active_done_25_generic()`), and the recipients' last lines say
|
||||
`deferred`. The tailer reads that line and closes the message's open rows -
|
||||
each recipient's newest, `queued` or `deferred` - as `bounced`.
|
||||
|
||||
**Queue reconcile** is the backstop for what the log cannot explain at all: a
|
||||
row still `queued` more than two minutes after it was accepted, whose queue id
|
||||
`postqueue -p` no longer lists, is marked `bounced` (swept every five minutes,
|
||||
row still open - `queued`, or `deferred` and waiting - more than two minutes
|
||||
after it was accepted, whose queue id `postqueue -p` no longer lists, is
|
||||
marked `bounced` with the reason `reconcile` (swept every five minutes,
|
||||
[internal/logtail](../internal/logtail/logtail.go),
|
||||
[postfix.QueueIDs](../internal/postfix/queue.go)). Postfix having dropped the
|
||||
message means nothing more will ever be reported about it, so the row can only
|
||||
be closed on an assumption, and it is closed as a failure because a delivery the
|
||||
panel cannot evidence must not be shown as one. Three things keep the sweep from
|
||||
panel cannot evidence must not be shown as one. What keeps the sweep from
|
||||
guessing where it need not: it starts only after the tailer has read to
|
||||
end-of-file once (on a restart the log itself holds the answer), the two-minute
|
||||
grace covers messages merely in flight, and a `postqueue` that cannot be read
|
||||
leaves every row untouched rather than closing them all. Now that the log
|
||||
survives the container, reaching this path means the lines are gone for good —
|
||||
rotated past fourteen files while the panel was down, or deleted.
|
||||
end-of-file once (on a restart the log itself holds the answer); the two-minute
|
||||
grace covers messages merely in flight; a `postqueue` that cannot be read, or
|
||||
whose listing is over the 4 MiB cap, leaves every row untouched rather than
|
||||
closing them all or those past the cut; and the close, by the row's id, is
|
||||
conditional on the row still being open, so a final status the log wrote in
|
||||
the meantime stands. A deferred row is the sweep's too because Postfix can
|
||||
drop a deferred message without a final line for its recipients: at expiry
|
||||
before `2.5.2` read that line, and with `postsuper -d`, which logs only
|
||||
`removed`. The listing's `#` - a message forced to expire - is read as part
|
||||
of the id's flags (`src/postqueue/showq_compat.c`, `showq_message()`).
|
||||
|
||||
**Which row a delivery line is.** The journal milter writes a row's
|
||||
recipient as Postfix will log it: the address the client gave, its quoted
|
||||
local part unquoted and quoted again only where it needs to be (`"ab"@x`
|
||||
becomes `ab@x`, `"a b"@x` stays; Postfix 3.10.13, `tok822_parse.c`,
|
||||
`quote_822_local.c`), case kept, and a recipient given twice - in any case -
|
||||
once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()`
|
||||
folding case). A delivery line names it in `orig_to=` when Postfix rewrote
|
||||
the address, in `to=` otherwise (`log_adhoc.c` writes `orig_to=` only then),
|
||||
and is matched without regard to case. It updates only the newest row of its
|
||||
queue id and recipient: Postfix reuses a short queue id once the message has
|
||||
left the queue, and an old row must not be rewritten. `UpdateStatus`'s transaction
|
||||
starts with a write, so a commit by the `dmarc-ingest` process in between
|
||||
cannot fail it with `database is locked`.
|
||||
Now that the log survives the container, reaching this path should mean the
|
||||
lines are gone for good — rotated past fourteen files while the panel was down,
|
||||
or deleted — or that the tailer was not reading.
|
||||
|
||||
Whether the sweep earns its place is measured, not argued (audit 2026-10-12,
|
||||
H3). `delivery_log_days` (migration `0007`) keeps, per UTC day for a year:
|
||||
`reconcile_closed`, the rows the sweep closed; `reconcile_missed`, those of
|
||||
them whose final line - sent, bounced, or the message's expiry - was in
|
||||
`mail.log` or `mail.log.1` when the sweep closed them - the tailer did not
|
||||
read a line that was there, which is what a lost line looks like (it is never
|
||||
read late; a deferred line is not one, a deferred row has it by definition); `reconcile_contradicted`, those
|
||||
a delivery line read later gave a status - the log's status replaces the
|
||||
sweep's and the row's reason becomes `reconcile-contradicted`, so a row counts
|
||||
once and can be found; `tailer_blind_seconds`, the time `follow()` could not
|
||||
read `mail.log` (the file gone, reopening it failing; stretches under three
|
||||
seconds - a rotation's tick between rename and create, which loses nothing -
|
||||
are not counted); and `queued_over_lifetime`, the rows still open (`queued`
|
||||
or `deferred`; `queued` only before `2.5.2`) past the queue lifetime plus a
|
||||
maximal backoff, as the day's last sweep saw them - rows
|
||||
stuck although the sweep runs (mail on hold, or a queue that could not be
|
||||
listed). What removing the sweep would leave queued is closed − missed −
|
||||
contradicted: the rows nothing in the log ever resolved. No page shows the
|
||||
table: it is read off a copy of the database. The rule, written before the
|
||||
data: `reconcile_closed = 0` for a month → remove the sweep; closed, but
|
||||
neither missed nor contradicted → keep it; missed or contradicted → the tailer
|
||||
loses or delays lines - fix the tailer (the two deferred steps: the rotation
|
||||
window, pausing the sweep while blind) and keep the sweep; if what remains
|
||||
after that is never resolved by the log, mark such rows `lost` or remove the
|
||||
sweep. Decided after thirty days, or sooner if `reconcile_missed` or
|
||||
`reconcile_contradicted` is ever non-zero.
|
||||
|
||||
**Stored lines** (`send_log_lines`, since 2.2.0;
|
||||
[internal/logtail/lines.go](../internal/logtail/lines.go)). A send-log row
|
||||
lives for the retention window, ninety days by default, and `mail.log` for a
|
||||
day before it is rotated; a message's page (`/outbound/log/{id}`) shows what
|
||||
Postfix wrote about it, so the tailer keeps those lines in the database as it
|
||||
reads them:
|
||||
|
||||
- **What is kept:** a line headed by a queue id — after the timestamp, the
|
||||
host name and `program[pid]:` — with that id, the line's own time and the
|
||||
line as written, cut to 4096 bytes. The id is read from the head of the line
|
||||
only: the tail is the far end's text. `NOQUEUE` and `warning` stand in the
|
||||
same place and are not ids; the pattern accepts Postfix's short form
|
||||
(capital hexadecimal) and its long form (no vowels).
|
||||
- **Port-25 mail is left out.** A queue id opened by `postfix/inbound/smtpd`
|
||||
is a stranger's message and never gets a send-log row; none of its lines
|
||||
are stored, up to qmgr's `removed` or until another listener opens a
|
||||
message under the id (4096 such ids are remembered). What a stranger can
|
||||
make Postfix log is therefore not written to the database at his rate.
|
||||
- **No check for a row at write time.** A message's first lines (`client=`,
|
||||
`message-id=`) are logged before the journal milter writes its row at
|
||||
end-of-message, so the tailer cannot ask whether the id is in the send log.
|
||||
Lines that never get a row — bounces Postfix generates, port-25 mail met in
|
||||
mid-flight after a restart — are kept for a while.
|
||||
- **Written per pass, not per line:** the lines of one pass over the log go
|
||||
in one transaction (`InsertSendLogLines`), 500 at most, before the read
|
||||
offset is saved. A commit per line would be a disk sync per line on the
|
||||
connection the journal milter shares.
|
||||
- **Retention is one rule:** a line older than an hour whose queue id no
|
||||
send-log row carries is deleted (`PruneSendLogLines`), at start and after
|
||||
every six-hourly send-log sweep. That removes the lines of rows the sweep
|
||||
deleted and the lines that never had a row. The send log is asked once per
|
||||
queue id; the statement still walks the table, with the connection taken
|
||||
meanwhile. Measured on the test server (2 cores, 2026-10-15) with 1.37
|
||||
million lines, 120 days at 1 900 messages a day: 0.4 s when there is
|
||||
nothing to delete, 1.9 s to delete 30 days' lines (342 000) after a gap -
|
||||
under `busy_timeout`'s 5 s, and the longest a second process's write waited
|
||||
meanwhile was 1.8 s (assumptions D-S7, D-arch:516).
|
||||
- **Which lines are a row's.** Postfix reuses a queue id once the message has
|
||||
left the queue, and a row outlives that by months. `SendLogLines` gives a row
|
||||
the lines of its queue id dated from one hour before the row was written to
|
||||
the queue lifetime (`maximal_queue_lifetime`, which `bounce_queue_lifetime`
|
||||
cannot exceed) plus a day after it. That limits a chance reuse of
|
||||
the id to those days; it does not exclude it. A message with several
|
||||
recipients is several rows under one id, and each row's page shows all of
|
||||
the message's lines.
|
||||
- **Read twice, stored once.** The read offset is saved every few seconds, so
|
||||
after an unclean stop some lines are read again; while the tailer is
|
||||
catching up at start it writes a line only if the same line is not there.
|
||||
- **Not stored:** what the tailer never read — the tail of a file rotated away
|
||||
while the panel was down.
|
||||
- **Size:** about six lines of about 150 bytes per message — more than the row
|
||||
itself (the live installation, 2026-10-15: 6.0 lines per queue id, 146 bytes
|
||||
on average, over its first 18 lines; assumptions D-arch:532). The table is in the database and therefore in a full backup, which
|
||||
`log/` is not ([security.md](security.md) § Backup and domain export).
|
||||
|
||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id). `QueueLines`
|
||||
is what a message's page falls back to when no lines are stored for its row —
|
||||
a message accepted before 2.2.0. It scans a bounded tail of the current file —
|
||||
finding a message's lines means reading rather than seeking — and matches the id
|
||||
anchored on the character before it, since queue ids are hexadecimal runs and a
|
||||
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||
(retention 90 days, rotation 14 files), so an empty result is the expected end
|
||||
state for an older message and the page reports it as such, not as a failure.
|
||||
shorter one is regularly the tail of a longer one. For such a message an empty
|
||||
result is the expected end state once the log has rotated, and the page reports
|
||||
it as such, not as a failure.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,6 +695,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
|
||||
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
|
||||
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
|
||||
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
|
||||
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
|
||||
| User menu | `GET /help` · `POST /logout` (public mux) |
|
||||
@@ -269,7 +739,11 @@ is not.
|
||||
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
|
||||
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
|
||||
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
|
||||
All three answer 404 alike.
|
||||
All three answer 404 alike. The **Inbound log** (`/inbound/log`) is open to
|
||||
anyone who reaches an inbound domain (`requireInbound`) and shows the rows
|
||||
of their domains only; **Filter lists** (`/inbound/filter-lists`) are the
|
||||
instance's and the global role's (`requireInboundGlobal`)
|
||||
([handlers/handlers_inbound_log.go](../internal/web/handlers/handlers_inbound_log.go)).
|
||||
- **The DMARC hub** is open to anyone with outbound reach for the reports of
|
||||
their own domains; the *Ingest* box (server-wide statistics) is rendered for
|
||||
the global role only.
|
||||
@@ -293,9 +767,15 @@ a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
|
||||
active on the page, every 30 s when the tab is visible but idle, and not at all
|
||||
when it is hidden — scheduled in `panel.js` via `data-poll`, not
|
||||
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
|
||||
Function`, which the CSP forbids. Polling does not extend the session idle
|
||||
timeout (only non-`HX-Request` `GET`s and mutating requests count as
|
||||
activity). The Outbound queue's retry-policy box is outside the polled region:
|
||||
Function`, which the CSP forbids. The next refresh is scheduled when a
|
||||
request of the box ends, however it ended: a refresh that failed - an error
|
||||
answer, the panel restarting, the network gone - is tried again after twice
|
||||
the wait, doubling with each failure in a row up to a minute, and the wait
|
||||
is back to normal after one that works. A request without a session answers
|
||||
htmx with `HX-Redirect: /login` (status 401), and htmx loads the sign-in
|
||||
page in place of the whole page; a browser gets the 303 as before. Polling
|
||||
does not extend the session idle timeout (only non-`HX-Request` `GET`s and
|
||||
mutating requests count as activity). The Outbound queue's retry-policy box is outside the polled region:
|
||||
it is the start-up `postconf -h` snapshot (see [Panel
|
||||
binary](#panel-binary-cmdpanel)), not a live re-read.
|
||||
|
||||
@@ -341,8 +821,9 @@ is one layout and one way to render.
|
||||
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
|
||||
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
|
||||
and `/static/` does not list its files. The kit page
|
||||
`GET /server/components` renders every partial in every state from fixtures
|
||||
(global role only).
|
||||
`GET /server/components` renders every partial in every state from the data
|
||||
of `KitPage` ([kit.go](../internal/web/view/kit.go), production code that the
|
||||
guard tests render too; global role only).
|
||||
|
||||
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
|
||||
They read their rules from the accepted mockups
|
||||
@@ -369,7 +850,7 @@ are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
||||
on every response: `Content-Security-Policy: default-src 'self'; object-src
|
||||
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
||||
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
|
||||
`Referrer-Policy: same-origin`, and `Strict-Transport-Security` when the cookie
|
||||
is `Secure`. Because the policy has no exemptions, no template carries an
|
||||
inline `<script>`, an event-handler attribute or `style="…"` — the page's
|
||||
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
|
||||
@@ -379,8 +860,13 @@ with scripts off. htmx is told not to inject its indicator stylesheet
|
||||
(`htmx-config` meta), which the CSP would otherwise have to allow.
|
||||
|
||||
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
|
||||
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
|
||||
panel's host; a request with neither header is let through (an accepted risk).
|
||||
first: `Sec-Fetch-Site` must say `same-origin`, or, where the browser does not
|
||||
send it (an older one, or a current one over plain HTTP at a name other than
|
||||
`localhost`), `Origin` must name the panel's host - which a form of the panel
|
||||
carries under its `Referrer-Policy: same-origin`, the page's own origin (under
|
||||
`no-referrer` it would be `null` and refused); a request with neither header
|
||||
is let through (an accepted risk). A refusal logs the `Sec-Fetch-Site`,
|
||||
`Origin` and `Host` it compared.
|
||||
There are no CSRF tokens — see [security.md](security.md).
|
||||
|
||||
### Errors shown to users
|
||||
@@ -498,9 +984,9 @@ flowchart TB
|
||||
dmarcSvc --> postfix
|
||||
```
|
||||
|
||||
The four roles inside the `panel` process (HTTP server, journal-milter,
|
||||
log-tailer, rate-limit recalc) share one binary and one SQLite handle on
|
||||
purpose — see
|
||||
The roles inside the `panel` process (HTTP server, sender check,
|
||||
journal-milter, log-tailer, rate-limit recalc, notifier) share one binary and
|
||||
one SQLite handle on purpose — see
|
||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||
single-connection trade-off that follows from it.
|
||||
|
||||
@@ -524,23 +1010,26 @@ single-connection trade-off that follows from it.
|
||||
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
|
||||
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
|
||||
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||
| `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done |
|
||||
| `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) |
|
||||
|
||||
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||
full backups also archive the operator's `./certs` PEM files when present.
|
||||
|
||||
### Database
|
||||
|
||||
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
|
||||
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
and the migrations after it (`0002` to `0011` today; the next is `0012`),
|
||||
applied by `store.Open` in file order; `PRAGMA user_version` counts the files
|
||||
applied and
|
||||
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
|
||||
chain. **2.0 starts from an empty data directory:** a database with a schema
|
||||
version but without the 2.x `application_id` — what every 1.x file looks like —
|
||||
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
|
||||
version is newer than the build knows. A restored 1.x backup fails the same
|
||||
way. Domains are carried across with a domain export, whose format is not the
|
||||
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
|
||||
released the baseline is never edited. History and the per-table list:
|
||||
database. A schema change is a new file; the baseline has not been edited
|
||||
since `2.0.0`. History and the per-table list:
|
||||
[schema-migrations.md](schema-migrations.md).
|
||||
|
||||
The tables that carry the panel's model:
|
||||
@@ -565,14 +1054,26 @@ The tables that carry the panel's model:
|
||||
(send-log retention). Nothing that belongs to a user.
|
||||
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
|
||||
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
|
||||
`send_log_lines` (what Postfix logged under a queue id — § Log tailer),
|
||||
`logtail_state`, `inbound_domains`, `inbound_transports`,
|
||||
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
|
||||
`inbound_recipients`, `inbound_spam_log` (port 25's decisions, § Postfix),
|
||||
`inbound_spam_list` (the filter lists), `dmarc_reports`,
|
||||
`dmarc_report_records`, `notify_state` and `notify_addressees` (what the
|
||||
notifier has said and to whom, § Notifications), `sender_check_days` (the
|
||||
sender check's downtime and the mail it deferred, per UTC day) and
|
||||
`delivery_log_days` (the queue sweep's measurements, § Log tailer).
|
||||
|
||||
The panel is the only writer; SQLite runs in WAL mode on one connection (see
|
||||
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
|
||||
with `VACUUM INTO` instead of copying the file.
|
||||
Two processes write: the panel, on one connection, and `dmarc-ingest`, which
|
||||
Postfix starts per report. SQLite runs in WAL mode (see
|
||||
[Panel binary](#panel-binary-cmdpanel)) and allows one write transaction at a
|
||||
time; every transaction that may write takes the lock at its `BEGIN`
|
||||
(`_txlock=immediate`, internal/store), so the other process waits for it
|
||||
within `busy_timeout`, and a schema migration is read and applied under that
|
||||
lock, once. A full backup snapshots with `VACUUM INTO`, which gives a
|
||||
consistent copy of a live database, instead of copying the file.
|
||||
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90), which
|
||||
the stored log lines follow;
|
||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||
|
||||
@@ -582,13 +1083,14 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
||||
version check on restore. Requires the project directory mounted read-only at
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
||||
tables, Postfix's sender map, inbound relay maps (when
|
||||
tables, Postfix's sender map, inbound relay maps and the filter lists (when
|
||||
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
|
||||
are re-derived from SQLite and both daemons are reloaded, so drift between the
|
||||
extracted archive and the database is healed before mail flows. Manual
|
||||
`POST /server/health/reload` (the *Reload configuration* button on Server ›
|
||||
Health) resyncs OpenDKIM, the sender map, and
|
||||
inbound maps only — not DMARC maps. Stopped-container
|
||||
Health) writes the same files in the same order (`HandleReload`; before
|
||||
`2.5.4` it left out the filter lists and the DMARC maps), and on a failure
|
||||
stops at that step and names it on the page. Stopped-container
|
||||
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||
|
||||
**Optional encryption** of the two secret-bearing downloads
|
||||
@@ -625,7 +1127,11 @@ unsupported rather than as a missing doc:
|
||||
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`),
|
||||
`SENDER_CHECK_SOCKET` (`/run/selfpost/sender.sock`),
|
||||
`INBOUND_JOURNAL_MILTER_SOCKET` (`/run/selfpost/inbound-journal.sock` — the
|
||||
inbound journal milter's socket, read by the panel, `build/postfix-config.sh`
|
||||
and `build/postfix-wrapper.sh`, which must agree), `MAIL_LOG`
|
||||
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||
here has to be matched in `build/postfix-config.sh`),
|
||||
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||
@@ -647,12 +1153,15 @@ unsupported rather than as a missing doc:
|
||||
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
|
||||
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose).
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
|
||||
(`/data/antispam` — the filter lists and the filter's key, which the spam
|
||||
filter's container mounts).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
||||
`postfix reload` to pick up renewed certificates),
|
||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — how
|
||||
often a renewed certificate is looked for; Postfix is reloaded only when one
|
||||
is found),
|
||||
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
||||
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
||||
rotation triggers `postfix reload`).
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div class="sp-pair">
|
||||
|
||||
@@ -37,7 +37,7 @@ SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
|
||||
# Selectors allowed to set a width. Everything else gets its width from the
|
||||
# layout it sits in — that is the whole point of the redesign.
|
||||
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
|
||||
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
|
||||
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf", ".sp-sheet")
|
||||
|
||||
|
||||
class Node:
|
||||
|
||||
@@ -84,15 +84,16 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
|
||||
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
|
||||
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
|
||||
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="buttons">
|
||||
@@ -104,23 +105,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="box" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
|
||||
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
|
||||
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
|
||||
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,23 +74,10 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="machine">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="processes" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -102,38 +89,21 @@
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<div class="box" id="certificate">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
|
||||
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="sockets">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="sender-check" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>≈ 41 s</td><td>3</td><td>1</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="hostname">
|
||||
<div class="box" id="hostname" data-order="5">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
@@ -142,14 +112,63 @@
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="machine" data-order="7">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="sockets" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="certificate" data-order="4">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="notification-mail" data-order="6">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="configuration" data-order="8">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
|
||||
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
|
||||
<li><a href="#sender-check"><i class="ti ti-shield-check"></i>Sender check</a></li>
|
||||
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
|
||||
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
|
||||
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
|
||||
@@ -97,12 +98,15 @@
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
|
||||
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
|
||||
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Server</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -114,34 +118,38 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#filter-lists" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="columns">
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client address or network</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
@@ -103,11 +103,11 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">bulk.example</td><td><button type="button" class="tag is-light" popovertarget="deny-1">domain</button><div class="sp-sheet" id="deny-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>bulk.example</h2><button type="button" popovertarget="deny-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>newsletter nobody subscribed to</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-02</td></tr><tr><th>Matched</th><td>41 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:57</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">41</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24</td><td><button type="button" class="tag is-light" popovertarget="deny-2">ip</button><div class="sp-sheet" id="deny-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">ip</span><h2>198.51.100.0/24</h2><button type="button" popovertarget="deny-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>dictionary attack on lists.example.org</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-08-19</td></tr><tr><th>Matched</th><td>1203 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 09:12</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">1203</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example</td><td><button type="button" class="tag is-light" popovertarget="deny-3">address</button><div class="sp-sheet" id="deny-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>win@lottery.example</h2><button type="button" popovertarget="deny-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>—</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-07-30</td></tr><tr><th>Matched</th><td>never in the last 90 days</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted sp-nowrap">never</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -116,10 +116,10 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">partner.example</td><td><button type="button" class="tag is-light" popovertarget="allow-1">domain</button><div class="sp-sheet" id="allow-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>partner.example</h2><button type="button" popovertarget="allow-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>invoices were scored as spam</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-11</td></tr><tr><th>Matched</th><td>7 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-19 16:40</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">7</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example</td><td><button type="button" class="tag is-light" popovertarget="allow-2">address</button><div class="sp-sheet" id="allow-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>noreply@github.example</h2><button type="button" popovertarget="allow-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>security advisories</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-06-04</td></tr><tr><th>Matched</th><td>18 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:30</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">18</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -130,10 +130,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
|
||||
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound</p>
|
||||
<h1 class="title is-3">Inbound log</h1>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,19 +83,20 @@
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
|
||||
@@ -79,7 +79,9 @@
|
||||
<form class="sp-end" method="get" action="#">
|
||||
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
|
||||
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>queued</option><option>sent</option><option>deferred</option><option>bounced</option><option>rejected</option></select></div>
|
||||
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
@@ -91,7 +93,8 @@
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -79,36 +79,22 @@
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Processes]:table",
|
||||
"box[Sender check]:table+body",
|
||||
"box[Hostname and reverse DNS]:body",
|
||||
"box[Machine]:table"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Processes]:table"
|
||||
"box[Milter sockets]:table",
|
||||
"box[TLS certificate]:body",
|
||||
"box[Notification mail]:table+body",
|
||||
"box[Configuration]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"is-4": [
|
||||
"box[TLS certificate]:body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-4": [
|
||||
"box[Milter sockets]:table"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-4": [
|
||||
"box[Hostname and reverse DNS]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"box[Configuration]:body"
|
||||
}
|
||||
],
|
||||
"help": [
|
||||
"head",
|
||||
@@ -122,9 +108,9 @@
|
||||
{
|
||||
"is-9": [
|
||||
"box[Overview]:help×3",
|
||||
"box[Outbound]:help×6",
|
||||
"box[Inbound]:help×4",
|
||||
"box[Server]:help×3",
|
||||
"box[Outbound]:help×7",
|
||||
"box[Inbound]:help×6",
|
||||
"box[Server]:help×4",
|
||||
"box[Account]:help"
|
||||
]
|
||||
}
|
||||
@@ -406,12 +392,14 @@
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Log retention]:body"
|
||||
"box[Log retention]:body",
|
||||
"box[Sending rate limits]:body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Sending rate limits]:body"
|
||||
"box[Notifications]:body",
|
||||
"box[Notification mail]:record×2"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -82,11 +82,12 @@
|
||||
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
|
||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||
<div class="field"><label class="label" for="inret">Keep inbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="inret" name="inbound_spam_log_retention_days" type="number" min="7" max="365" value="90" required></div>
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -97,6 +98,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div class="sp-pair">
|
||||
|
||||
@@ -18,15 +18,16 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
|
||||
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
|
||||
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
|
||||
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="buttons">
|
||||
@@ -38,23 +39,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="box" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
|
||||
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
|
||||
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="box" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
|
||||
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,23 +8,10 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="machine">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="processes" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -36,38 +23,21 @@
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<div class="box" id="certificate">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
|
||||
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="sockets">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="box" id="sender-check" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>≈ 41 s</td><td>3</td><td>1</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="box" id="hostname">
|
||||
<div class="box" id="hostname" data-order="5">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
@@ -76,13 +46,62 @@
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="machine" data-order="7">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="sockets" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="certificate" data-order="4">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="notification-mail" data-order="6">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="configuration" data-order="8">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
|
||||
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
|
||||
<li><a href="#sender-check"><i class="ti ti-shield-check"></i>Sender check</a></li>
|
||||
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
|
||||
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
|
||||
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
|
||||
@@ -31,12 +32,15 @@
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
|
||||
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
|
||||
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Server</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -48,34 +52,38 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#filter-lists" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="columns">
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client address or network</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
@@ -37,11 +37,11 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">bulk.example</td><td><button type="button" class="tag is-light" popovertarget="deny-1">domain</button><div class="sp-sheet" id="deny-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>bulk.example</h2><button type="button" popovertarget="deny-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>newsletter nobody subscribed to</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-02</td></tr><tr><th>Matched</th><td>41 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:57</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">41</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24</td><td><button type="button" class="tag is-light" popovertarget="deny-2">ip</button><div class="sp-sheet" id="deny-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">ip</span><h2>198.51.100.0/24</h2><button type="button" popovertarget="deny-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>dictionary attack on lists.example.org</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-08-19</td></tr><tr><th>Matched</th><td>1203 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 09:12</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">1203</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example</td><td><button type="button" class="tag is-light" popovertarget="deny-3">address</button><div class="sp-sheet" id="deny-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>win@lottery.example</h2><button type="button" popovertarget="deny-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>—</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-07-30</td></tr><tr><th>Matched</th><td>never in the last 90 days</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted sp-nowrap">never</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -50,10 +50,10 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
|
||||
<tr><td class="sp-mono sp-small">partner.example</td><td><button type="button" class="tag is-light" popovertarget="allow-1">domain</button><div class="sp-sheet" id="allow-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>partner.example</h2><button type="button" popovertarget="allow-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>invoices were scored as spam</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-11</td></tr><tr><th>Matched</th><td>7 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-19 16:40</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">7</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example</td><td><button type="button" class="tag is-light" popovertarget="allow-2">address</button><div class="sp-sheet" id="allow-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>noreply@github.example</h2><button type="button" popovertarget="allow-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>security advisories</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-06-04</td></tr><tr><th>Matched</th><td>18 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:30</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">18</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -64,10 +64,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
|
||||
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound</p>
|
||||
<h1 class="title is-3">Inbound log</h1>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
|
||||
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,17 +17,18 @@
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><button type="button" class="tag is-success is-light" popovertarget="why-2">accept</button><div class="sp-sheet" id="why-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>add header, score 9.80 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">PHISHING(5.00)<br>FORGED_SENDER(3.00)<br>R_SPF_FAIL(1.00)</td></tr><tr><th>Subject</th><td>Outstanding invoice — action required</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-3">reject</button><div class="sp-sheet" id="why-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>postfix</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-danger is-light" popovertarget="why-4">reject</button><div class="sp-sheet" id="why-4" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>Why</h2><button type="button" popovertarget="why-4" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">5.7.1 Spam message rejected</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip sp-muted">—</td><td><button type="button" class="tag is-warning is-light" popovertarget="why-5">tempfail</button><div class="sp-sheet" id="why-5" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-warning is-light">tempfail</span><h2>Why</h2><button type="button" popovertarget="why-5" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">4.7.1 Try again later</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><button type="button" class="tag is-success is-light" popovertarget="why-6">accept</button><div class="sp-sheet" id="why-6" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-6" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score -98.10 / 15.00</td></tr><tr><th>Symbols</th><td class="sp-mono sp-small">SELFPOST_ALLOW_FROM(-100.00)<br>R_DKIM_ALLOW(-0.20)</td></tr><tr><th>Subject</th><td>[advisory] new report</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">A decision opens its reason · Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in</span></div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<form class="sp-end" method="get" action="#">
|
||||
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
|
||||
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
|
||||
<div class="select"><select name="status"><option>Any status</option><option>queued</option><option>sent</option><option>deferred</option><option>bounced</option><option>rejected</option></select></div>
|
||||
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
|
||||
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
|
||||
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
|
||||
</form>
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
@@ -25,7 +27,8 @@
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:55</td><td>shop@<wbr>shop.example.org</td><td>k.berg@<wbr>web.de</td><td class="sp-clip">A message from the director</td><td><span class="tag is-danger is-light">wrong From</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
|
||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||
<div class="field"><label class="label" for="inret">Keep inbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="inret" name="inbound_spam_log_retention_days" type="number" min="7" max="365" value="90" required></div>
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -31,6 +32,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -269,4 +269,49 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
.sp-record-head { flex-wrap: wrap; }
|
||||
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
|
||||
.sp-postmark { display: none; }
|
||||
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
|
||||
.sp-cards > .column { display: contents; }
|
||||
.sp-cards .box { margin-bottom: 1.5rem; }
|
||||
.sp-cards [data-order="1"] { order: 1; }
|
||||
.sp-cards [data-order="2"] { order: 2; }
|
||||
.sp-cards [data-order="3"] { order: 3; }
|
||||
.sp-cards [data-order="4"] { order: 4; }
|
||||
.sp-cards [data-order="5"] { order: 5; }
|
||||
.sp-cards [data-order="6"] { order: 6; }
|
||||
.sp-cards [data-order="7"] { order: 7; }
|
||||
.sp-cards [data-order="8"] { order: 8; }
|
||||
.sp-cards [data-order="9"] { order: 9; }
|
||||
.sp-cards [data-order="10"] { order: 10; }
|
||||
.sp-cards [data-order="11"] { order: 11; }
|
||||
.sp-cards [data-order="12"] { order: 12; }
|
||||
}
|
||||
|
||||
/* cards: two stacks of boxes that are read in rows - the odd ones on the left,
|
||||
the even ones on the right, each stack closed up so a short box leaves no
|
||||
gap beside a tall one. On a phone the stacks dissolve and the boxes come in
|
||||
the order of their numbers: each box carries data-order, its own number (the
|
||||
rules above, up to twelve), wherever it stands in its stack. */
|
||||
|
||||
/* sheet: what does not fit a table row, opened over the page from the row's
|
||||
tag. It is the browser's own popover (the tag is a button with
|
||||
popovertarget), so it needs no script: Esc, a click outside and the close
|
||||
button all shut it, and focus returns to the tag. It rises from the bottom
|
||||
edge across the whole window - the one component that is not laid out by the
|
||||
page, hence the widths - with its content, a table of name and value, in a
|
||||
column of reading width in the middle. */
|
||||
.sp-sheet {
|
||||
inset: auto 0 0 0; width: auto; margin: 0; padding: 0; border: 0; border-top: 3px solid var(--sp-brick);
|
||||
max-height: 75vh; overflow: auto; background: #fff; color: inherit; box-shadow: 0 -12px 40px rgba(40, 20, 15, .22);
|
||||
}
|
||||
.sp-sheet-in { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem; }
|
||||
.sp-sheet::backdrop { background: rgba(40, 20, 15, .38); }
|
||||
.sp-sheet-head { display: flex; align-items: center; gap: .7rem; padding: 1rem 0 .8rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
.sp-sheet-head h2 { font-size: 1.15rem; font-weight: 600; margin-right: auto; }
|
||||
.sp-sheet-head button { border: 0; background: none; cursor: pointer; font-size: 1.3rem; color: var(--sp-muted); }
|
||||
.sp-sheet-body { padding: .4rem 0 1.4rem; }
|
||||
/* The sheet's content is a table of name and value: the name is a label, set like the dt of facts. */
|
||||
.sp-sheet .table th { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); white-space: nowrap; vertical-align: top; width: 1%; padding-right: 2rem; }
|
||||
.sp-sheet .table td { overflow-wrap: anywhere; }
|
||||
/* A tag that opens a sheet is a button: it keeps the tag's look and says it can
|
||||
be pressed with the dotted underline links to more detail carry. */
|
||||
button.tag { border: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+108
-18
@@ -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
|
||||
[§ Release image](#release-image)).
|
||||
|
||||
**Planned version cuts** (from pin `2.0.0`; adjust if semver changes mid-track):
|
||||
**Planned version cuts** (from pin `2.7.0`; adjust if semver changes mid-track):
|
||||
|
||||
| Stage | ID | Cut |
|
||||
|---|---|---|
|
||||
@@ -124,7 +124,22 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
| 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) |
|
||||
| 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 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
|
||||
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
|
||||
| 16 | delivery-log-storage | `2.2.0` |
|
||||
| 17 | panel-notifications | `2.3.0` |
|
||||
| 18 | from-header-binding | `2.4.0` |
|
||||
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
|
||||
| 20 | audit-2026-10-12 stage 0: release hygiene | `2.4.2` (PATCH) |
|
||||
| 21 | audit-2026-10-12 stages 1 and 2 | `2.5.0` |
|
||||
| 22 | audit-2026-10-12 stage 2b: the log tailer | `2.5.1` |
|
||||
| 23 | audit-2026-10-12 stage 2c: the sender check reads From: as OpenDKIM does | `2.5.2` |
|
||||
| 24 | audit-2026-10-12 stage 3: persistence and restore | `2.5.3` |
|
||||
| 25 | audit-2026-10-12 stage 4: panel behaviour | `2.5.4` |
|
||||
| 26 | audit-2026-10-12 stage 5: image and Postfix hardening | `2.6.0` |
|
||||
| 27 | port25-bare-newline | `2.6.1` (PATCH) |
|
||||
| 28 | base-trixie | `2.7.0` |
|
||||
| 29 | panel-dkim-signing | `2.8.0` |
|
||||
|
||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||
commit rule but **no** version cut until the next product stage ships.
|
||||
@@ -139,8 +154,8 @@ commit rule but **no** version cut until the next product stage ships.
|
||||
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) |
|
||||
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
|
||||
| **Container** | Docker + Compose v2 on the dev host and in CI |
|
||||
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) |
|
||||
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate |
|
||||
| **Image (build stage)** | `golang:1.26-trixie` (bookworm until `2.7.0`) — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
|
||||
| **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, OpenDKIM, supervisord, SASL, logrotate |
|
||||
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
|
||||
| **Image registry** | `ghcr.io/mixeme/selfpost` |
|
||||
|
||||
@@ -196,13 +211,65 @@ the image.
|
||||
### Debian packages in the runtime image
|
||||
|
||||
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||
file on <https://packages.debian.org/bookworm/>.
|
||||
from Debian trixie repositories; licenses are in each package's `copyright`
|
||||
file on <https://packages.debian.org/trixie/>.
|
||||
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
|
||||
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
|
||||
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
|
||||
the OFL text at `/static/OFL.txt`.
|
||||
|
||||
### Code that relies on another component's behaviour
|
||||
|
||||
The owner's rule (2026-10-14): what another program does is read in its
|
||||
documentation or its source - never guessed. SelfPost reads Postfix's log
|
||||
lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
|
||||
`postconf`, `supervisorctl` and `db_dump` output, talks the milter protocol
|
||||
through go-milter, relies on SQLite's locking, on logrotate's and Docker's
|
||||
behaviour. Every block of code that does so carries a comment that says
|
||||
what it relies on and where that is written, for the version the image runs
|
||||
(the Debian trixie package, not upstream's head):
|
||||
|
||||
```go
|
||||
// Postfix 3.10.13, src/smtpd/smtpd_check.c,
|
||||
// reject_auth_sender_login_mismatch(): the SASL login is compared with
|
||||
// each owner in sender_login_maps by strcasecmp_utf8.
|
||||
```
|
||||
|
||||
The Debian sources of the image's versions are fetched with `apt-get source`
|
||||
inside a `debian:trixie` container (`postfix`, `opendkim`, `cyrus-sasl2`,
|
||||
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
|
||||
(not in git) is where they are kept for reading, and `dev/upstream-bookworm/`
|
||||
holds the bookworm ones the citations named before `2.7.0`. Go modules are
|
||||
read in the module cache at the version `go.mod` pins.
|
||||
|
||||
The image takes `debian:trixie-slim` and installs without pinning (pinned
|
||||
Debian versions leave the mirrors), so a rebuild after a point or security
|
||||
release runs packages the citations were not read against. The versions
|
||||
read are these; the e2e stand compares them with `dpkg-query` in the image
|
||||
it built (`checkCitedPackageVersions`), and a difference fails the run -
|
||||
re-read what changed, update the citations, then this table:
|
||||
|
||||
<!-- cited-package-versions -->
|
||||
| Package | Version read |
|
||||
|---|---|
|
||||
| `postfix` | `3.10.13-0+deb13u1` |
|
||||
| `opendkim` | `2.11.0~beta2-9.1+b1` |
|
||||
| `libopendkim11` | `2.11.0~beta2-9.1+b1` |
|
||||
| `libmilter1.0.1` | `8.18.1-6+b1` |
|
||||
| `libsasl2-2` | `2.1.28+dfsg1-9` |
|
||||
| `sasl2-bin` | `2.1.28+dfsg1-9` |
|
||||
| `supervisor` | `4.2.5-3` |
|
||||
| `logrotate` | `3.22.0-1` |
|
||||
<!-- /cited-package-versions -->
|
||||
|
||||
A document instead of source: its URL and section. Where neither answers the
|
||||
question, the comment says so and what was measured instead - "measured on
|
||||
the e2e stand, 2026-10-14: ..." - and a measurement is never written as if
|
||||
it were read. A test fixture copied from a real log says where and when it
|
||||
was seen; one composed by hand says that it was composed. The heuristics
|
||||
review of 2026-10-14 (audit-2026-10-12, stage 2c) brought the code that
|
||||
existed then up to this rule.
|
||||
|
||||
---
|
||||
|
||||
## Building binaries and the image
|
||||
@@ -264,13 +331,22 @@ The release image is published **only** for a SemVer version `X.Y.Z`: a
|
||||
that supplies that version. Pushing a git tag alone does not publish. Ordinary
|
||||
commits, and a dispatch from `main` without a version input, do not publish.
|
||||
The version is the single source that drives the image tag and `-ldflags` in
|
||||
the binaries so they cannot drift apart.
|
||||
the binaries so they cannot drift apart. The e2e gate rebuilds the image it
|
||||
then pushes, so `release.yml` hands it the version as `SELFPOST_E2E_VERSION`;
|
||||
the suite's `image_stamped_with_version` and a check right before the push
|
||||
both compare `panel -version` and `selfpost-backup -version` with it. (Until
|
||||
`2.4.2` the rebuild stamped every published image `e2e`; those images stay as
|
||||
they are.)
|
||||
|
||||
**Steps (on explicit request):**
|
||||
|
||||
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
|
||||
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||
local-trial image references) in the **same** release commit.
|
||||
local-trial image references) in the **same** release commit. While no
|
||||
image of the current line is published, README, guide § Fixed image tag
|
||||
and the compose file's header say so and show the local build under the
|
||||
pinned name; bump the version there too. The first publication of the
|
||||
line removes those notes.
|
||||
2. Create and push git tag `vX.Y.Z` on that commit.
|
||||
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
|
||||
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||
@@ -348,9 +424,14 @@ go test ./...
|
||||
|
||||
### Env documentation regression
|
||||
|
||||
`go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
|
||||
— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md)
|
||||
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
|
||||
`go test ./cmd/panel -run 'TestEnvironment'` compares what the code reads with
|
||||
what the documents name ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
|
||||
Read: the `envDefault` / `envInt` / `envBool` / `envSeconds` / `envDuration` /
|
||||
`os.Getenv` / `os.LookupEnv` calls in `cmd/panel` and `internal` (tests aside),
|
||||
and `${VAR:-…}` in `build/*.sh`. Named: [guide.md](guide.md) § Environment
|
||||
variables (the table and the paragraph under it) and the internal list in
|
||||
[architecture.md](architecture.md) § Configuration. A variable read and named
|
||||
nowhere fails the first test; one named and read nowhere fails the second.
|
||||
|
||||
### Panel pages: the component kit and its guards
|
||||
|
||||
@@ -390,19 +471,24 @@ make e2e
|
||||
[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same
|
||||
`cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports
|
||||
(`20465`/`20587`/`20080`), test hostname, self-signed TLS,
|
||||
`PANEL_COOKIE_SECURE=false`, isolated compose project. Mail is hermetic:
|
||||
`PANEL_COOKIE_SECURE=false`, isolated compose project, and the image's
|
||||
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). Mail is hermetic:
|
||||
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
|
||||
the panel and published into the zone — the test verifies the records an
|
||||
operator would actually use.
|
||||
|
||||
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
|
||||
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
|
||||
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
|
||||
`docker restart`); startup checks that supervisord actually brought up
|
||||
L1/L2 limits, the panel down or hung mid-message (Postfix defers the mail
|
||||
rather than letting it through: `panel_down_defers_mail`,
|
||||
`panel_hung_mid_message_is_counted`), bad `SELFPOST_HOSTNAME`, session
|
||||
survives `docker restart`); startup checks that supervisord actually brought up
|
||||
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
|
||||
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
|
||||
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
|
||||
Polling with timeouts only — no fixed `sleep`.
|
||||
Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says
|
||||
why where it stands (`sender_check_test.go`: a downtime worth a second or two,
|
||||
and 35 s to show a row is not written again).
|
||||
|
||||
Requires **Docker + Compose v2** on the machine running the suite.
|
||||
|
||||
@@ -417,7 +503,9 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||||
|
||||
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
|
||||
`go test ./...` (main module, no e2e; includes the panel guard tests, see
|
||||
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)).
|
||||
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
|
||||
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
|
||||
`test/e2e`).
|
||||
|
||||
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||
|
||||
@@ -435,7 +523,8 @@ release: published
|
||||
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
||||
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||||
→ docker build --load (VERSION from prepare)
|
||||
→ e2e (test/e2e)
|
||||
→ e2e (test/e2e; rebuilds selfpost:e2e with SELFPOST_E2E_VERSION=X.Y.Z)
|
||||
→ check panel / selfpost-backup -version = X.Y.Z
|
||||
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||||
→ merge
|
||||
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||||
@@ -497,7 +586,8 @@ Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
||||
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
|
||||
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
|
||||
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
|
||||
fails on an undocumented `loadConfig` or build-script key.
|
||||
fails on a variable the code or a build script reads that no document names,
|
||||
and on one a document names that nothing reads.
|
||||
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||
silent drive-by edits.
|
||||
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
|
||||
|
||||
+496
-99
@@ -33,6 +33,7 @@ changed (see the CHANGELOG, *Removed*).
|
||||
- [Queue and System log](#queue-and-system-log)
|
||||
- [Account](#account)
|
||||
- [Settings](#settings)
|
||||
- [Notifications](#notifications)
|
||||
- [Users](#users)
|
||||
- [Sessions](#sessions)
|
||||
- [Upgrading](#upgrading)
|
||||
@@ -63,8 +64,28 @@ changed (see the CHANGELOG, *Removed*).
|
||||
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
|
||||
listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
|
||||
**`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
|
||||
[DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
|
||||
ports in external scans.
|
||||
[DMARC reports](#dmarc-reports)). A scan over IPv4 sees such a port closed -
|
||||
the connection is refused. Over IPv6 the connection goes through Docker's
|
||||
proxy (the host runs `docker-proxy` on `::` for every published port), which
|
||||
accepts it and then closes it, so a scanner may report it open. Measured on a
|
||||
live server, 2026-10-15, Docker 29.6.1, with a published port nothing
|
||||
listened on: refused from outside over IPv4 and from the host's public IPv4
|
||||
address; accepted and closed through `127.0.0.1`, the same proxy. IPv6 itself
|
||||
was not probed for this.
|
||||
|
||||
Since `2.7.0` the image runs Postfix 3.10, whose defaults hold every
|
||||
listener - 465 and 587 as well as 25 - to two rules of SMTP: a message ends
|
||||
only at the standard `<CR><LF>.<CR><LF>` (a client that ends DATA with a
|
||||
bare newline waits for a reply that does not come), and a client that sends
|
||||
commands ahead of the server's replies where RFC 2920 does not allow it is
|
||||
answered `554 5.5.0 Error: SMTP protocol synchronization` and disconnected.
|
||||
|
||||
Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
|
||||
where the application runs: the `Received:` header SelfPost adds reads
|
||||
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
|
||||
without the application's address, host name or login. To trace a message
|
||||
someone complains about, take that queue id to *Outbound › Log*; mail.log
|
||||
keeps the client address on smtpd's `client=` line for the same id.
|
||||
|
||||
### Local trial
|
||||
|
||||
@@ -183,7 +204,18 @@ cat ./data/setup-token
|
||||
#### Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `2.0.0`. Intermediate
|
||||
deliberately never `:latest`. The current pin is `2.7.0`. **No 2.x image is
|
||||
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
||||
it from the checkout under the pinned name, and Compose uses the local image
|
||||
instead of pulling:
|
||||
|
||||
```sh
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.7.0 .
|
||||
```
|
||||
|
||||
`VERSION` must equal the tag: it is the version the backup check compares
|
||||
(see below). Intermediate
|
||||
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
|
||||
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||||
@@ -204,19 +236,20 @@ expected to set; defaults match the code exactly.
|
||||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound › Domains* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
|
||||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *Outbound › DMARC reports*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip, zip or plain XML, and show summaries under *Outbound › DMARC reports*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `20` | `.env` |
|
||||
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. The panel reads it to report on the *Spam filter* box of the inbound pages and to check that the filter answers (Server › Health). | *(empty)* | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). The *Spam filter* box states which. | `accept` | `.env` |
|
||||
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
|
||||
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. | `26214400` (25 MiB) | `.env` |
|
||||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (refuse with a temporary error). The *Spam filter* box states which. | `accept` | `.env` |
|
||||
| `INBOUND_SPAM_LOG_RETENTION_DAYS` | How long the inbound journal keeps a row, 7-365 days; it also never holds more than 10 000 rows. Seeds the setting on the first start with the inbound relay on. | `90` | `.env` |
|
||||
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on port 25: how many times one client IP may start a message (`MAIL FROM`) per window, accepted or not (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
|
||||
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. This is port 25's whole limit; 465 and 587 keep Postfix's own, 10 240 000 bytes. | `26214400` (25 MiB) | `.env` |
|
||||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: how many times one client IP may start a message (`MAIL FROM`) on one listener per window, accepted or not; 465 and 587 count separately (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||||
| `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 — the main driver of `/data` growth over time. 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` |
|
||||
| `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` |
|
||||
| `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. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.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
|
||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||
@@ -241,14 +274,14 @@ SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
||||
|
||||
| Proxy | Where certs live | Fragment |
|
||||
|---|---|---|
|
||||
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||||
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — `certs/` links to them, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||
| nginx | Host disk, via a certbot sidecar container — the same shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||||
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
||||
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
||||
|
||||
Apache is the recommended default because the certbot Apache plugin already
|
||||
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
||||
extra moving parts between "certificate issued" and "Postfix can read it."
|
||||
keeps `fullchain.pem`/`privkey.pem` at a predictable path with no extra moving
|
||||
parts between "certificate issued" and "Postfix can read it."
|
||||
|
||||
**The proxy needs no security configuration of its own.** The panel emits its
|
||||
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
||||
@@ -257,15 +290,18 @@ that's easy to get wrong lives in the service rather than in a config file
|
||||
somebody edits under pressure. There is exactly one thing the proxy must do:
|
||||
**pass the original `Host` header through**. All four fragments above already
|
||||
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
||||
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
||||
reject every form submission as cross-origin — the log says so explicitly,
|
||||
printing the `Origin` and `Host` it compared.
|
||||
and Traefik by default). A proxy that rewrites `Host` instead breaks the panel in a
|
||||
browser that does not send `Sec-Fetch-Site` (Safari before 16.4, or any
|
||||
browser on a panel served over plain HTTP at a name): there the panel compares
|
||||
`Origin` with `Host` and refuses every change as cross-origin - the log says
|
||||
so explicitly, printing the `Sec-Fetch-Site`, `Origin` and `Host` it compared.
|
||||
|
||||
In every case the proxy terminates HTTPS for the panel; the resulting
|
||||
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
|
||||
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` —
|
||||
the files, or links that lead to them inside the container.
|
||||
|
||||
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
|
||||
`proxy_http` enabled. Copy
|
||||
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`,
|
||||
`proxy_http` and `rewrite` enabled. Copy
|
||||
[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your
|
||||
vhost directory, replace `mail.example.com` with your hostname, enable the site,
|
||||
then issue a certificate:
|
||||
@@ -274,26 +310,68 @@ then issue a certificate:
|
||||
sudo certbot --apache -d mail.example.com
|
||||
```
|
||||
|
||||
Point `./certs` at the PEM files certbot wrote (symlink is fine):
|
||||
Link the two files into `./certs` and let the container see what the links
|
||||
lead to. certbot's `live/` files are themselves relative links into
|
||||
`archive/` (certbot's documentation, "Where are my certificates?", advises
|
||||
pointing servers at them or linking to them), so the whole of
|
||||
`/etc/letsencrypt` is mounted, read-only and at the same path — uncomment that
|
||||
line in `docker-compose.yml`. Mounting `live/mail.example.com` alone, or
|
||||
linking `certs` to it, gives the container links into a directory it does not
|
||||
have: Postfix then starts without a certificate.
|
||||
|
||||
```sh
|
||||
ln -s /etc/letsencrypt/live/mail.example.com certs
|
||||
mkdir certs
|
||||
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
|
||||
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
|
||||
```
|
||||
|
||||
**nginx (containerised).** From the `deploy/` directory, merge the nginx
|
||||
fragment and issue the first certificate before nginx can serve HTTPS:
|
||||
The panel reads them as its own user (Health shows the certificate, a full
|
||||
backup carries both files), through the container's group 10998. certbot's
|
||||
documentation gives the way for a server that is not root, and certbot keeps
|
||||
the key's group and mode at every renewal:
|
||||
|
||||
```sh
|
||||
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
|
||||
sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
|
||||
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
|
||||
```
|
||||
|
||||
**nginx (containerised).** Edit
|
||||
[deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
|
||||
replace `mail.example.com` first. Then, from the `deploy/` directory, issue
|
||||
the first certificate - before nginx can serve HTTPS, so certbot answers the
|
||||
challenge itself (`--standalone`), and port 80 must be free while it runs.
|
||||
The `certbot` service's own entrypoint is its renewal loop, which ignores
|
||||
any arguments, hence `--entrypoint certbot`:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
|
||||
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
```
|
||||
|
||||
The fragment mounts certbot's directory into both nginx and SelfPost, at
|
||||
`/etc/letsencrypt`; `./certs` holds two links to
|
||||
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
|
||||
container, not on the host — and the panel reads the key through group 10998,
|
||||
as in the Apache case. Then start everything:
|
||||
|
||||
```sh
|
||||
mkdir certs
|
||||
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
|
||||
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
|
||||
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
|
||||
sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
|
||||
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
|
||||
|
||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
```
|
||||
|
||||
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
|
||||
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
|
||||
both nginx and SelfPost.
|
||||
Renewals cannot use the standalone method: nginx holds port 80 by then. The
|
||||
fragment's renewal loop runs `certbot renew --webroot -w /var/www/certbot`,
|
||||
the directory nginx serves the challenges from; certbot renews with the
|
||||
options it is given and keeps them for the certificate once a renewal with
|
||||
them succeeds (certbot's documentation, "Renewing certificates").
|
||||
|
||||
**Caddy (containerised, automatic ACME).** Edit
|
||||
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
|
||||
@@ -319,6 +397,10 @@ docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
||||
|
||||
Whatever the proxy, SelfPost notices a renewed certificate in the mount within
|
||||
a day (`TLS_RELOAD_INTERVAL_SECONDS`) and starts serving it on 465 and 587
|
||||
without a restart.
|
||||
|
||||
## Instance administration
|
||||
|
||||
After sign-in the global role opens on **Overview** — the place to answer "is
|
||||
@@ -374,31 +456,51 @@ and reverse DNS. A check the panel could not run shows as a warning, never as
|
||||
fine. Below the cards are the outbound domain list and, with the inbound relay
|
||||
on, the inbound one. Each card leads to its table on **Server › Health**
|
||||
(`/server/health`) — the queue card to the Outbound queue — and Health has the
|
||||
readings:
|
||||
readings, in the order of what stops mail first:
|
||||
|
||||
- **Machine** — processor (core and thread counts), memory and swap, and
|
||||
per-interface network throughput and totals, read from the kernel's
|
||||
counters; CPU and throughput are measured between refreshes, so they appear
|
||||
- **Machine** — processor (core and thread counts), memory and swap of the
|
||||
host (a memory limit set on the container is not reflected), and
|
||||
per-interface network throughput and totals of the container's own network
|
||||
interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning, because both delay or kill the mail path; throughput is
|
||||
only reported.
|
||||
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
|
||||
sockets** (OpenDKIM and the journal-milter).
|
||||
sockets** (OpenDKIM, the sender check and the journal-milter).
|
||||
- **Sender check** — what the strict check of the `From:` line has cost
|
||||
today, in the last 30 days and in the last 12 months: for how long the
|
||||
panel did not answer while Postfix was running, how many connections
|
||||
Postfix turned away because of it, and how many messages it refused with a
|
||||
temporary error in the middle (see [Who an application may send
|
||||
as](#who-an-application-may-send-as)). A restart of the whole container is
|
||||
not counted: mail was stopped for another reason then. A connection turned
|
||||
away is refused before a message is offered, so a monitor that only
|
||||
connects during an outage is counted too; a message refused in the middle
|
||||
is one the panel stopped answering for after it had begun. A time marked ≈
|
||||
is at most what it shows: at that start supervisord could not say since
|
||||
when Postfix ran. Days are UTC days. The card warns when
|
||||
a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay
|
||||
at nothing, the panel is in the way of your mail more than it should be -
|
||||
report it.
|
||||
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
|
||||
record the internet publishes for this server's IP (forward-confirmed
|
||||
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
|
||||
*Re-check DNS* after changing DNS.
|
||||
- **Configuration** — the **Reload configuration** button re-applies OpenDKIM
|
||||
tables and the Postfix sender map from the database (and inbound relay maps
|
||||
when `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the
|
||||
panel shows after manual edits under `/data`. It does **not** rebuild DMARC
|
||||
Postfix maps; a full restore Resync does (see [Restore](#restore)).
|
||||
- **Configuration** — the **Reload configuration** button writes again, from
|
||||
the database, every file the panel keeps for the mail path - OpenDKIM's
|
||||
tables, the Postfix sender map, the inbound relay maps and the filter lists
|
||||
(with `INBOUND_RELAY_ENABLE=true`), the DMARC report maps (with
|
||||
`DMARC_REPORTS_ENABLE=true`) - and has OpenDKIM and Postfix re-read them:
|
||||
what the first start after a restore does (see [Restore](#restore)). Use it
|
||||
if the daemons drifted from what the panel shows after manual edits under
|
||||
`/data`. A step that fails is named on the page, and the panel's log says
|
||||
why.
|
||||
|
||||
### Queue and System log
|
||||
|
||||
- **Outbound › Queue** (`/outbound/queue`, global role) — live view of
|
||||
messages Postfix is still trying to deliver or deferring, as `postqueue -p`
|
||||
lists them. It only shows: nothing there deletes or forces a message. A box
|
||||
messages Postfix is still trying to deliver, deferring or holding, as
|
||||
`postqueue -p` lists them. It only shows: nothing there deletes or forces a message. A box
|
||||
states this instance's retry policy — first retry delay, later backoff cap,
|
||||
how long a message stays in the queue — from `postconf -h`, read once when
|
||||
the panel starts. A `postconf -e` override inside the container is visible
|
||||
@@ -417,10 +519,11 @@ readings:
|
||||
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
|
||||
(username and e-mail) and **Password**. Saving the profile does not ask for the
|
||||
password; changing the password asks for the current one and is rate-limited
|
||||
like a sign-in. The e-mail is the user's own address. Nothing is sent to it
|
||||
yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level
|
||||
DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate
|
||||
and are not changed here.
|
||||
like a sign-in. The e-mail is the user's own address: it is where the panel
|
||||
sends [notifications](#notifications) while *Send me notifications* is ticked
|
||||
beside it, and what a domain's *Use my e-mail* button fills in (see
|
||||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
|
||||
are separate and are not changed here.
|
||||
|
||||
### Settings
|
||||
|
||||
@@ -429,7 +532,58 @@ the whole instance: how many days the **Outbound log** keeps its rows before the
|
||||
background sweep deletes them (7–365 days; takes effect on the next six-hour
|
||||
prune cycle without a container restart), and, read-only, the level-1 rate
|
||||
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
|
||||
There is no panel-wide DMARC report address: each domain has its own.
|
||||
There is no panel-wide DMARC report address: each domain has its own. The page
|
||||
also has the switch for [notifications](#notifications) and the two DNS records
|
||||
that make them arrive.
|
||||
|
||||
### Notifications
|
||||
|
||||
The panel writes to its users when something needs them. Four things are
|
||||
reported, each once:
|
||||
|
||||
| What | When a message is sent | A second message |
|
||||
|---|---|---|
|
||||
| A sending domain's DKIM, SPF or DMARC record stops matching | after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification | when two checks in a row find the records right again |
|
||||
| A level-2 rate limit refuses mail of a sending domain's applications | within a minute of the first refusal | at most once a day for the same domain, while it goes on |
|
||||
| The TLS certificate is about to expire | 14 days before | 3 days before, if it is still the same certificate |
|
||||
| A message has waited in the queue for more than three days | at the next hourly check | when no message that old is left |
|
||||
|
||||
A user with the **global** role is told all of it; a user with the **domain**
|
||||
role is told about the DNS records and rate limits of the outbound domains
|
||||
assigned to them. A message goes to the e-mail on the user's
|
||||
[Account](#account) page and only while *Send me notifications* is ticked
|
||||
there; a user without an e-mail gets none. **Server › Settings** has one switch
|
||||
that turns notifications off for everyone. With it off nothing is checked, so
|
||||
what is wrong when it is turned on again is reported then.
|
||||
|
||||
Notifications are the panel's own mail. They are sent from
|
||||
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
|
||||
the Outbound log and count against no rate limit. They name the page of the
|
||||
panel to open and carry no link: the panel does not know the address its
|
||||
reverse proxy serves it under.
|
||||
|
||||
**Two DNS records make them arrive.** The server's own name is not one of your
|
||||
sending domains, so it needs records of its own, shown with the values to
|
||||
publish under **Server › Settings › Notification mail**:
|
||||
|
||||
- a **DKIM** key for the hostname, which the panel generates and signs
|
||||
notifications with (`<selector>._domainkey.<hostname>`, TXT);
|
||||
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT).
|
||||
|
||||
**Server › Health** shows whether each is in place in the *Notification mail*
|
||||
card. Without them notifications are still sent, and a receiver may file them
|
||||
as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
|
||||
Mail of your sending domains depends on neither. If the hostname is itself one
|
||||
of your sending domains, the DKIM record is that domain's and is already
|
||||
published - and whoever administers that domain can then send mail that
|
||||
looks like the panel's; a hostname that is nobody's sending domain is the
|
||||
better choice.
|
||||
|
||||
The e-mail on Account may be at a public mail host (Gmail and the like); only
|
||||
a domain's DMARC report address may not.
|
||||
|
||||
A relay that cannot send cannot say so: none of the four is "the relay is
|
||||
down". Watch that from outside ([Container health](#container-health)).
|
||||
|
||||
### Users
|
||||
|
||||
@@ -490,8 +644,9 @@ left open will not keep a session alive forever.
|
||||
|
||||
### Upgrading
|
||||
|
||||
Bump the pinned image tag in `docker-compose.yml` to the target release, then
|
||||
`docker compose up -d`. The backup version check requires the running image
|
||||
Bump the pinned image tag in `docker-compose.yml` to the target release, build
|
||||
that tag as under [Fixed image tag](#fixed-image-tag) while no 2.x image is
|
||||
published, then `docker compose up -d`. The backup version check requires the running image
|
||||
to match the version that created a full backup — see [Fixed image
|
||||
tag](#fixed-image-tag).
|
||||
|
||||
@@ -536,16 +691,31 @@ Level 1 is always on, configured via `.env`:
|
||||
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
||||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||
|
||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||
This is an anvil limit per client IP and listener. It keeps working even if the
|
||||
journal-milter (level 2) is down. There is no per-IP bypass except the
|
||||
container's own loopback.
|
||||
|
||||
**The client IP is the real one over IPv4 only.** Docker's port publishing
|
||||
keeps an IPv4 client's address, but the compose network has no IPv6, so a
|
||||
client that reaches the server over IPv6 is carried by Docker's proxy and
|
||||
arrives from the network's gateway (`172.18.0.1` by default) - and so does a
|
||||
connection from the host to `127.0.0.1`. All such clients share one level-1
|
||||
budget, an application's client IP list cannot name them, and the spam
|
||||
filter sees the gateway. Measured on a live server, 2026-10-15, Docker
|
||||
29.6.1: an outside IPv4 client and the host's own public IPv4 address logged
|
||||
as themselves, the host's IPv6 address and `127.0.0.1` as `172.18.0.1`.
|
||||
|
||||
### Full backup and restore
|
||||
|
||||
**Full backup** is a self-contained project archive: `data/` (SQLite, all
|
||||
domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
|
||||
`manifest.json` with the version that created it), plus `docker-compose.yml`,
|
||||
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
|
||||
logs under `data/log/` are excluded. The base compose file mounts the project
|
||||
domains' DKIM keys, all applications' SASL credentials, `manifest.json` with
|
||||
the version that created it), plus `docker-compose.yml`, `.env`, and `certs/`
|
||||
from the operator directory next to `./data`. Delivery logs under `data/log/`
|
||||
are excluded, and so is the Postfix queue: the archive is taken while the
|
||||
instance runs, and a copy of a queue Postfix is working on can hold a message
|
||||
twice or not at all. Mail still waiting for delivery is therefore not carried
|
||||
over, and a restored instance starts with an empty queue; to keep the queue,
|
||||
stop the instance and use `tar` (methods 3 and 4 below). The base compose file mounts the project
|
||||
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
|
||||
deploy files — without that mount, *Full backup* refuses with an error.
|
||||
|
||||
@@ -556,21 +726,43 @@ instance keeps running, what goes into the archive, and what happens on restore.
|
||||
|
||||
**1. Panel — while the container is running (no downtime).** *Server › Backup*
|
||||
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
|
||||
archive. Same archive as the CLI below.
|
||||
archive. Same archive as the CLI below. The panel runs as its own user, not as
|
||||
root, so **the private key in `certs/` has to be readable by it**: give the
|
||||
file to the container's `selfpost` group, which has the fixed id 10998 and holds
|
||||
both the panel and Postfix —
|
||||
|
||||
```sh
|
||||
chgrp 10998 certs/privkey.pem && chmod 640 certs/privkey.pem
|
||||
```
|
||||
|
||||
— and have whatever renews the certificate do the same; certbot does it by
|
||||
itself, keeping the group and mode of a key on renewal (with `certs/` as links
|
||||
into `/etc/letsencrypt`, the commands of "Reverse proxy" above give certbot's
|
||||
own files the group). A key readable by root alone is enough for Postfix, but
|
||||
then the panel refuses to make a backup and names the file: it does not hand
|
||||
over an archive without the key. Links under `certs/` are followed: the
|
||||
archive holds the files they lead to. A `certs/` that is there with no file
|
||||
in it is refused for the same reason; what a backup leaves out - a link to
|
||||
a directory under `certs/`, a link under `data/` - it names in a warning (the
|
||||
CLI's standard error, the panel's log).
|
||||
|
||||
**2. `selfpost-backup` CLI — while the container is running (no downtime).**
|
||||
The panel button and this command call the same code path: a consistent SQLite
|
||||
snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus
|
||||
deploy files.
|
||||
deploy files. `docker exec` runs the command as root, so it reads the private
|
||||
key whatever its permissions.
|
||||
|
||||
```sh
|
||||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
```
|
||||
|
||||
Use `-o /path/inside/container` to write inside the container instead of
|
||||
stdout. Optional encryption: set `SELFPOST_BACKUP_PASSWORD` or pass
|
||||
`-password-file` — see [Encrypting a backup or
|
||||
export](#encrypting-a-backup-or-export).
|
||||
stdout; the file is written beside that name and takes it only when it is
|
||||
whole, so a backup that fails leaves no half-written file and whatever had
|
||||
the name before. A database that is not where `SELFPOST_DB_PATH` points is
|
||||
refused, rather than archived empty. Optional encryption: set
|
||||
`SELFPOST_BACKUP_PASSWORD` or pass `-password-file` — see [Encrypting a
|
||||
backup or export](#encrypting-a-backup-or-export).
|
||||
|
||||
**3. `tar` of the whole project directory — with the service stopped.** Stop
|
||||
the instance so nothing is writing to SQLite, then archive the operator
|
||||
@@ -616,6 +808,8 @@ methods avoid it by stopping first.
|
||||
| Self-contained archive | Yes | Yes | No — deploy files separate |
|
||||
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
|
||||
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
|
||||
| Postfix queue | No | Yes | Yes |
|
||||
| `certs/` private key | Yes (the panel needs it readable by group 10998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
|
||||
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
|
||||
| Restore version check | Yes — must match image tag | No | No |
|
||||
| Post-restore *Resync* | Yes, on first boot | No | No |
|
||||
@@ -638,8 +832,10 @@ blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM
|
||||
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
|
||||
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
|
||||
and both daemons are reloaded, healing any drift between the extracted files and
|
||||
the database. The *Reload configuration* button on Server › Health resyncs
|
||||
OpenDKIM, the sender map, and inbound maps only — not DMARC maps. This is why the
|
||||
the database. The manifest is deleted only after that Resync is done: a first
|
||||
start that fails on the way leaves it, and the next start is a restore again
|
||||
and runs the Resync. The *Reload configuration* button on Server › Health runs the same Resync.
|
||||
This is why the
|
||||
compose file pins a fixed tag rather than `:latest`: without a known version,
|
||||
there'd be no way to tell which image restoring a given backup actually requires
|
||||
(see [Fixed image tag](#fixed-image-tag)).
|
||||
@@ -659,7 +855,7 @@ back after a bad change):
|
||||
docker compose down
|
||||
|
||||
# 2. Move the current project aside rather than deleting it
|
||||
mv . ../selfpost.before-restore
|
||||
cd .. && mv selfpost selfpost.before-restore
|
||||
mkdir selfpost && cd selfpost
|
||||
|
||||
# 3. Unpack the backup into the fresh directory
|
||||
@@ -678,18 +874,50 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||
the panel exits with a message naming the tag to use, e.g.:
|
||||
|
||||
```
|
||||
backup: this backup was created by SelfPost 1.2.3 but this image is 1.5.0 — restore into the matching image (selfpost:1.2.3)
|
||||
backup: this backup was created by SelfPost 2.6.1 but this image is 2.7.0 — restore into the matching image (selfpost:2.6.1)
|
||||
```
|
||||
|
||||
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
||||
-d` again — the manifest is still there because the failed boot never got to
|
||||
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
|
||||
tag](#fixed-image-tag) if it is not published), then `docker compose up -d`
|
||||
again — the manifest is still there because the failed boot never got to
|
||||
delete it.
|
||||
|
||||
An archive unpacked **in part** — a download cut short, or a `tar` that
|
||||
stopped at a damaged block (it says so and exits non-zero) — is refused at
|
||||
step 5 as well, before the database is opened:
|
||||
|
||||
```
|
||||
backup: the archive was not unpacked whole (data/contents.json, its last entry, is not there) - it was cut short or is damaged; nothing was opened. Unpack it again, from an intact copy, into an empty directory
|
||||
```
|
||||
|
||||
The last entry of every panel and CLI archive, `data/contents.json`, records
|
||||
each file under `data/` with its size and SHA-256, and the first start
|
||||
compares `/data` with it; a file that differs is named in the message.
|
||||
`docker-compose.yml`, `.env` and `certs/` are not compared — they are yours to
|
||||
edit before that start. A journal file beside the database
|
||||
(`selfpost.db-wal`, `-shm`, `-journal`) is refused too: no archive holds one,
|
||||
so it is left from what the directory held before, and SQLite would take it
|
||||
up. To unpack again, unpack into an **empty** directory.
|
||||
|
||||
The files are compared once: a first start that gets past the comparison
|
||||
and then fails (its Resync, say) leaves the manifest, and the next
|
||||
`docker compose up -d` runs the Resync without comparing again - that start
|
||||
had the database open, so its files are no longer the archive's.
|
||||
|
||||
**Moving to a different host** is the same flow: create an empty project
|
||||
directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
|
||||
needed) for the new hostname or proxy, then `docker compose up -d`. The archive
|
||||
carries `certs/` from the old host — re-issue certificates when the hostname or
|
||||
IP changes. Set up the reverse-proxy vhost separately (not in the backup).
|
||||
carries `certs/` from the old host — re-issue certificates when the hostname
|
||||
changes; a new IP needs only DNS (the A record and the PTR). Set up the reverse-proxy vhost separately (not in the backup).
|
||||
|
||||
The inbound log and the filter lists are in the database and so in the
|
||||
backup, and so are the list files under `data/antispam/`; the panel writes
|
||||
those files again from the database on the first start after a restore. What
|
||||
the backup does not hold is the spam filter's own side: the `antispam/`
|
||||
directory with the compose fragment and the rspamd configuration. Like the
|
||||
reverse-proxy fragments it is part of the installation, not of its state -
|
||||
copy it from [deploy/antispam/](../deploy/antispam/) again, or the restored
|
||||
instance runs with no filter.
|
||||
|
||||
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
||||
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
||||
@@ -760,12 +988,27 @@ unpacked into `/data`, which the CLI does with the same password:
|
||||
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
```
|
||||
|
||||
The password is checked on the file's first block, before the command
|
||||
writes anything - but the shell's `>` has already emptied `backup.tar.gz`
|
||||
before the command starts, so a wrong password leaves it empty; `-o` keeps
|
||||
a file that was there. A file cut short fails at its end, with the command's
|
||||
exit status non-zero, after what came before has been written: an archive
|
||||
cut so is refused at the restore when it lost any file (see
|
||||
[Restore](#restore) - the record is the archive's last entry). With `-o` the
|
||||
output takes its name only when it is whole; a command killed outright
|
||||
leaves a `.NAME.tmp-*` file beside it, readable by its owner alone, to
|
||||
remove by hand.
|
||||
|
||||
The CLI also *writes* encrypted backups for scripted/cron use. The password
|
||||
comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>` (first line),
|
||||
never from a command-line argument, which would be visible in the process list:
|
||||
must be at least 12 characters, as on the panel's forms (decrypting takes
|
||||
any), and it comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>`
|
||||
(first line), never from a command-line argument, which would be visible in
|
||||
the process list.
|
||||
`-e` with the name only takes the value from your shell's environment, so it is
|
||||
not written on the command line:
|
||||
|
||||
```sh
|
||||
docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > backup.spbk
|
||||
SELFPOST_BACKUP_PASSWORD="$PW" docker exec -e SELFPOST_BACKUP_PASSWORD <container> selfpost-backup > backup.spbk
|
||||
```
|
||||
|
||||
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||
@@ -781,11 +1024,19 @@ SelfPost never stores the message locally.
|
||||
container. Until then there is no inbound-relay `smtp inet` listener, no
|
||||
*Inbound* group in the menu, and `/inbound/…` is 404. (Port 25 can still
|
||||
listen when only `DMARC_REPORTS_ENABLE=true` — see [DMARC
|
||||
reports](#dmarc-reports).) Outbound 465/587 is unchanged.
|
||||
reports](#dmarc-reports).) Outbound 465/587 is unchanged. Port 25 ends a
|
||||
message only at the standard `<CR><LF>.<CR><LF>` (the SMTP-smuggling
|
||||
defence, since `2.6.1`); a sending server that ends DATA with a bare
|
||||
newline waits for a reply that does not come, and there is no allow-list
|
||||
for such a sender.
|
||||
|
||||
**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 the mode saved for one
|
||||
domain is the mode of every domain forwarded to the same host and port, and
|
||||
they are changed with it; a *domain* user who does not manage all of them is
|
||||
refused, and told the mode in use. 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,
|
||||
@@ -799,7 +1050,9 @@ with nowhere to send it.
|
||||
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
|
||||
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
|
||||
server — other MX targets (a primary mail server) are fine; this is how
|
||||
backup-MX is meant to work. Use *Re-check DNS* after publishing.
|
||||
backup-MX is meant to work. When SelfPost is the backup of another MX, give it
|
||||
a larger number than the primary's: MX records of equal preference are tried
|
||||
in no fixed order, so the mail would be shared, not backed up. Use *Re-check DNS* after publishing.
|
||||
|
||||
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
|
||||
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
|
||||
@@ -817,10 +1070,64 @@ the same way as the nginx/Caddy fragments:
|
||||
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||||
```
|
||||
|
||||
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
|
||||
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||||
The milter sees the real client IP (over IPv4; see [Rate limiting — level
|
||||
1](#rate-limiting--level-1-ip-backstop) for IPv6), HELO and PTR — unlike the
|
||||
upstream, which only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||||
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
|
||||
defer instead.
|
||||
refuse with a temporary error instead, so the sending server tries again.
|
||||
|
||||
The fragment mounts one file into the sidecar,
|
||||
[deploy/antispam/rspamd/milter_headers.conf](../deploy/antispam/rspamd/milter_headers.conf):
|
||||
it makes rspamd write its verdict - the action and the score with its symbols
|
||||
- into two headers of every message it lets through. SelfPost records them
|
||||
with the message, and the upstream receives them with the mail. Copy the
|
||||
`antispam/rspamd/` directory next to the fragment; without the file accepted
|
||||
mail is still recorded, with no verdict.
|
||||
|
||||
The same file makes rspamd **sign** each message, in a third header,
|
||||
`X-SelfPost-Filter`. The filter is attached so that mail still flows when it
|
||||
is down, and then a sender could write a verdict of his own into those
|
||||
headers; SelfPost therefore records a verdict only next to a signature it can
|
||||
recompute. The key is `data/antispam/filter.key`: SelfPost creates it on its
|
||||
first start with the inbound relay on, and the sidecar reads it from the
|
||||
directory it already mounts - there is nothing to configure. If Inbound › Log
|
||||
shows *selfpost* for mail the filter did judge, the sidecar is running without
|
||||
this file: copy `antispam/rspamd/` again and restart the sidecar. Nothing of
|
||||
rspamd's needs clearing, then or after a change of the lists or of the key: it
|
||||
rereads the list files by itself and reads the key anew for every message. A
|
||||
restart is needed only for a change of its own configuration - the files of
|
||||
`antispam/rspamd/`.
|
||||
|
||||
The same directory holds
|
||||
[multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the
|
||||
**allow and deny lists** kept in the panel. SelfPost does not apply the lists
|
||||
itself: it writes them as six files under `data/antispam/` (`allow_ip.map`,
|
||||
`allow_from.map`, `allow_domain.map` and the three `deny_` ones), the fragment
|
||||
mounts that directory into the sidecar, and rspamd rereads a changed file within
|
||||
about a minute - no restart. An entry is a sender address, a sender
|
||||
domain (that domain, not its subdomains) or a client address or network. A
|
||||
denied sender is refused outright. An allowed one is given a weight that no
|
||||
spam score outweighs, so it is accepted - unless a deny entry matches the same
|
||||
message: **deny wins**, whichever entries they are. **A sender address or
|
||||
domain on the allow list is not proof of anything**: it is the envelope
|
||||
sender, which the sending server states and nobody checks, so whoever knows an
|
||||
allowed address can send as it. Allow a client address or network where you
|
||||
can. Only the envelope sender is looked at: a bounce, whose envelope sender
|
||||
is empty, matches no sender entry, whatever its `From:` line says. The files are readable by
|
||||
anyone on the host; they hold addresses, not secrets.
|
||||
|
||||
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
||||
port 25 decided about each message is **Inbound › Log**: accepted mail with the
|
||||
filter's verdict, refused mail with the reply it was given. A row of the log
|
||||
leads to the lists with its sender filled in. Beside each entry of a list is
|
||||
how often it decided a message, counted from the log - so over the days the
|
||||
log keeps - and *never* marks an entry that may have outlived its use. The
|
||||
count depends on two things in
|
||||
[multimap.conf](../deploy/antispam/rspamd/multimap.conf): the message of the
|
||||
deny rules and the names of the allow symbols; change them and every entry
|
||||
reads *never*. A user with the *domain* role
|
||||
sees the log of the inbound domains assigned to them, and not the lists. How
|
||||
long the log keeps a row is set under **Server › Settings**.
|
||||
|
||||
The panel only reports this setting; it does not change it. Every inbound
|
||||
domain page has a **Spam filter** box — on or off, what happens to mail while
|
||||
@@ -831,12 +1138,12 @@ so a user with the *domain* role sees the state without the server's internals.
|
||||
For the global role the filter is also one of the milter sockets on **Server ›
|
||||
Health** and in the Overview card: the panel connects to the address (one
|
||||
second at most, nothing is sent) and, if there is no answer, says whether
|
||||
inbound mail is being deferred (`tempfail`) or going through unfiltered
|
||||
inbound mail is being refused with a temporary error (`tempfail`) or going through unfiltered
|
||||
(`accept`).
|
||||
|
||||
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
|
||||
the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
|
||||
mail (and a signing outage therefore cannot defer it), and the journal-milter
|
||||
mail (and a signing outage therefore cannot hold it up), and the journal-milter
|
||||
does not file it — the [Outbound log](#outbound-log) stays a record of what
|
||||
*you* sent.
|
||||
This also keeps a forged `From:` on inbound mail from counting against a
|
||||
@@ -881,8 +1188,9 @@ is no mailbox for the server as a whole.
|
||||
|
||||
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
||||
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
|
||||
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
|
||||
address domain if different) so reports reach this server. If a hub domain
|
||||
address's domain: to its MX, or, without one, to its A record -
|
||||
`SELFPOST_HOSTNAME` has that already; a report address on another domain needs
|
||||
that domain's **MX** to point here. If a hub domain
|
||||
authorises external destinations, publish `_report._dmarc` there too; the panel
|
||||
checks it on the domain page.
|
||||
|
||||
@@ -891,7 +1199,13 @@ sees the *Ingest* box — whether ingest works, the last report received, report
|
||||
kept this week and parse failures. Open a domain's roll-up from the list or
|
||||
from *DMARC reports* in the domain page's menu. A user with the *domain* role
|
||||
sees the reports of the domains assigned to them, without the *Ingest* box.
|
||||
Summaries are pruned (500 kept, 90 days max).
|
||||
Summaries are pruned (500 kept for each domain, 90 days max).
|
||||
|
||||
A message that is not a report the relay keeps — unreadable as an aggregate
|
||||
report, about a domain not sent from here, or delivered to another domain's
|
||||
report address — is dropped without a retry and without a bounce. It adds one
|
||||
to *parse failures*, and the reason is on the message's delivery line in the mail log
|
||||
(*Server › System log*, `dropped: …`).
|
||||
|
||||
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
|
||||
alone, `check_recipient_access` permits only configured report addresses;
|
||||
@@ -929,12 +1243,62 @@ to a domain:
|
||||
who the application may send as, its client IP allow-list and its rate limit
|
||||
are validated and saved together, or not at all, also when the application
|
||||
is created. A password is shown once, on the page that answers the creation
|
||||
or *New password*, and is never shown again.
|
||||
or *New password*, and is never shown again. A new login is stored in lower
|
||||
case - Postfix does not tell `Ops` from `ops`, so the panel does not let two
|
||||
applications be told apart by case - and the page that shows the password
|
||||
shows the login as it was stored.
|
||||
- **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC
|
||||
reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting —
|
||||
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain*
|
||||
and, for the global role, *Delete domain*, which asks once more.
|
||||
|
||||
#### Who an application may send as
|
||||
|
||||
An application sends as any address of its domain, or only as the addresses
|
||||
listed for it. Since `2.4.0` that rule covers both senders a message has:
|
||||
|
||||
- the **envelope sender** (`MAIL FROM`), which Postfix checks;
|
||||
- the **`From:` line** of the message - the one a mail program shows and the
|
||||
one the DKIM signature is chosen by - which the panel checks.
|
||||
|
||||
An address with an extension counts as the address: an application listed as
|
||||
`invoices@…` may also send as `invoices+march@…`, in the envelope and in
|
||||
`From:` alike.
|
||||
|
||||
An address listed for an application is that application's alone: another
|
||||
application of the same domain, set to any address, may not send as it -
|
||||
Postfix asks who owns the address before it asks who owns the domain.
|
||||
|
||||
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
||||
text naming the line: another of your domains, the server's own name, an
|
||||
address at a domain that is not on this server, a line with two addresses,
|
||||
or no `From:` at all. The line is read the way the signer, OpenDKIM, reads
|
||||
it - the panel runs OpenDKIM's own parser - so only its address counts: what a
|
||||
display name or a comment around it says is not judged, and a line with more
|
||||
than one address is refused. A display name with a comma or brackets has to
|
||||
be quoted (`"Shop, Inc." <orders@…>`), as the mail standard asks and mail
|
||||
libraries do; unquoted, OpenDKIM may take a word of the name for the
|
||||
address. The refusal
|
||||
is permanent, so the application does not send the message again; it is a
|
||||
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||||
page shows what the line said.
|
||||
|
||||
What this stops working when you upgrade from an earlier version:
|
||||
|
||||
- an application that sends with the `From:` of another of your domains -
|
||||
give that domain an application of its own;
|
||||
- an application with a list whose envelope address and `From:` address
|
||||
differ - add the second address to the list;
|
||||
- a contact form that puts its visitor's address into `From:` - put it into
|
||||
`Reply-To:` and send from an address of your own, which is also what
|
||||
DMARC requires.
|
||||
|
||||
The check is strict: while the panel does not answer, Postfix answers with a
|
||||
temporary error (`451`, and `454` to the login): an application that queues
|
||||
its mail sends it again later, one that sends directly gets an error to retry;
|
||||
nothing is accepted unchecked. **Server › Health** counts how often
|
||||
that happens.
|
||||
|
||||
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
|
||||
Backup* reads one back in (**global role only**) — see [Exporting and importing
|
||||
a single domain](#exporting-and-importing-a-single-domain).
|
||||
@@ -960,9 +1324,9 @@ records; see [Inbound relay](#inbound-relay).
|
||||
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||
report destinations.
|
||||
|
||||
Skipping any of the three records is the single most common reason mail
|
||||
lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||
Publish all three: receivers weigh SPF, DKIM and DMARC together, and large
|
||||
receivers require them for bulk senders - a domain without them is a common
|
||||
way for mail SelfPost delivered correctly to land in spam. **Whenever you add a new domain in the
|
||||
panel, add its DNS records at the same time**, not later.
|
||||
|
||||
Each domain's page has a *DNS records* box comparing the published DKIM
|
||||
@@ -1005,11 +1369,14 @@ application: when enabled, list one or more client IPs that may **submit** mail
|
||||
as that application; a session from any other address is refused with a 4xx at
|
||||
`MAIL FROM`. When disabled, any client IP is allowed. This is independent of
|
||||
rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
|
||||
An application that connects over IPv6 is seen from the Docker network's
|
||||
gateway, not its own address (see [Rate limiting — level
|
||||
1](#rate-limiting--level-1-ip-backstop)).
|
||||
|
||||
It is **not an authentication boundary.** The SASL login itself still succeeds;
|
||||
the check runs in the journal-milter, on the same fail-open path as level 2
|
||||
(below), so if the milter is unavailable the restriction is skipped rather than
|
||||
enforced. Use it to narrow where an application may send from, not to contain a
|
||||
(below), so if that milter does not answer the restriction is skipped rather
|
||||
than enforced. Use it to narrow where an application may send from, not to contain a
|
||||
leaked password — for that, regenerate the password.
|
||||
|
||||
**Level 2 — application** — optional override of the domain limit for one
|
||||
@@ -1032,21 +1399,42 @@ hours and on demand via **Recalculate now**.
|
||||
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||
a store error, or the connecting client's IP is not available to the
|
||||
milter, level 2 is skipped and the message is accepted rather than held up.
|
||||
The same applies if the milter itself is down — Postfix is configured to
|
||||
accept rather than defer when it cannot reach it. Level 1 is the backstop
|
||||
that keeps working even when level 2 cannot run, and it is the only one of
|
||||
the two that does not depend on the panel process.
|
||||
The same applies if the journal-milter itself does not answer — Postfix is
|
||||
configured to accept rather than refuse it when it cannot reach it. (Since
|
||||
`2.4.0` a panel that is down altogether makes Postfix refuse mail with a
|
||||
temporary error anyway, through the
|
||||
[sender check](#who-an-application-may-send-as) of the same process.) Level 1
|
||||
is the backstop that keeps working even when level 2 cannot run, and it is
|
||||
the only one of the two that does not depend on the panel process.
|
||||
|
||||
### Outbound log
|
||||
|
||||
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
|
||||
filters by domain and application; a user with the *domain* role sees only the
|
||||
rows of their domains. It refreshes its rows without resetting a filter being
|
||||
chosen. A row identifies its message and nothing more — time,
|
||||
filters by domain, application, status and day (from and to, UTC days, as on
|
||||
the Inbound log); the status *rejected* is the three kinds of refusal
|
||||
together, which the table tells apart by their tags. A user with the *domain*
|
||||
role sees only the rows of their domains. The filters are kept by the pages,
|
||||
the refresh and the way back from a message, and the log refreshes its rows
|
||||
without resetting a filter being chosen. A row identifies its message and nothing more — time,
|
||||
sender, recipient, subject and status `queued` (accepted, not yet
|
||||
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||||
retrying), `bounced` (final failure), or `rejected` (refused — for example
|
||||
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
||||
retrying), `bounced` (final failure: refused for good by the receiving server, or given
|
||||
up when the queue lifetime ran out), or refused before it was queued.
|
||||
`bounced` is usually Postfix's word - it returns a message it gave up on to
|
||||
the sender, as it does one refused for good; it is the panel's when Postfix no longer holds a message and `mail.log` never
|
||||
said what became of it (the log rotated
|
||||
away while the panel was down, or was not readable) - the panel then closes
|
||||
the row as a failure rather than show a delivery it cannot evidence, and
|
||||
counts that it did. A
|
||||
refused row says why in its tag: `rate-limited` (a [level-2 rate
|
||||
limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the
|
||||
application's client IP list) or `wrong From` (see [Who an application may
|
||||
send as](#who-an-application-may-send-as)); a row refused before `2.4.0`
|
||||
says `rejected`. A refused message's page shows what Postfix wrote about the
|
||||
refusal: for `wrong From` the lines of the queue id the message had, for the
|
||||
other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line
|
||||
was not kept, and one the panel wrote from `mail.log` after a restart - it
|
||||
had stopped before writing the refusal itself - says so;
|
||||
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
|
||||
the sending domain, the application it was submitted under, the Postfix
|
||||
queue id and the journal id, beside the message's history — when it was
|
||||
@@ -1056,8 +1444,12 @@ backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
|
||||
with the *domain* role see them here because they cannot open the queue. Under
|
||||
both sit the `mail.log` lines for its queue id: the connection to the
|
||||
receiving server, the server's reply, and the status that reply was filed
|
||||
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
||||
away; the page says so. Retention is set under **Server › Settings** (global
|
||||
as. The lines are kept in the database with the row, so they stay on the
|
||||
page after `mail.log` has rotated. They go at the next six-hourly prune after
|
||||
the row is deleted by retention, and not while another row carries the same
|
||||
queue id (Postfix reuses ids); a
|
||||
message accepted before `2.2.0` shows them only while they are still in the
|
||||
current file, and the page says so when there are none. Retention is set under **Server › Settings** (global
|
||||
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
|
||||
is changed there.
|
||||
|
||||
@@ -1070,7 +1462,12 @@ applications' **working** SASL passwords, and configured **rate limits**
|
||||
instance without regenerating anything, so DNS (the DKIM TXT record)
|
||||
doesn't need to change. Unlike a full restore (see [Full backup and
|
||||
restore](#full-backup-and-restore)), this works across different
|
||||
hostnames/instances. *Import* is for the global role only; *export* is
|
||||
hostnames/instances. The file is checked like the forms before anything is
|
||||
created - the report address, every rate limit, every client address - and
|
||||
refused with the form's own message, naming the field and the application.
|
||||
An application login keeps the case it had (new logins are created in lower
|
||||
case), and one that differs from a login of this server only in case is
|
||||
refused. *Import* is for the global role only; *export* is
|
||||
available to any user who can access the domain, **including a user with the
|
||||
*domain* role** for a domain assigned to them — so that user can walk away with
|
||||
the domain's working SASL passwords, in the clear if they untick the encryption
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# Own builds of Postfix and OpenDKIM - the analysis of 2026-10-15
|
||||
|
||||
**Question (owner, 2026-10-15; roadmap [own-mta-builds](roadmap.md#own-mta-builds)):**
|
||||
is it worth patching Postfix and OpenDKIM and shipping our own builds, to
|
||||
optimise something in SelfPost or to lift a limit we work around?
|
||||
|
||||
**Answer:** no to carrying patched builds of either - nothing we work around
|
||||
needs a patch, and a patch set makes one maintainer the security team of
|
||||
two MTAs. The question does point at two real problems, each with a cheaper
|
||||
answer:
|
||||
|
||||
1. **The image's base is out of date.** `debian:bookworm-slim` left regular
|
||||
security support on 2026-07-12; its Postfix `3.7.11` is code of mid-2024,
|
||||
and the three Postfix security batches of 2026 (June, August, September)
|
||||
carry no CVE ids, so nothing will bring them to bookworm's LTS. The
|
||||
answer is Debian trixie (`postfix 3.10.13`, the August batch included),
|
||||
not a build of our own: proposed as roadmap row
|
||||
[base-trixie](roadmap.md#base-trixie).
|
||||
2. **OpenDKIM has no upstream left.** The package is a beta of 2018; three
|
||||
CVEs of 2026 are open in every Debian suite with "the vendor did not
|
||||
respond"; Debian's bug on them has had no answer since 2026-10-03. Every
|
||||
limit we have worked around is OpenDKIM's. A patched OpenDKIM of our own
|
||||
would make us its maintainer; signing in the panel's own milter, which
|
||||
already runs in the mail path, removes the program and most of what was
|
||||
built around it: proposed as roadmap row
|
||||
[panel-dkim-signing](roadmap.md#panel-dkim-signing).
|
||||
|
||||
A side finding of the reading: port 25 of the inbound relay accepts bare
|
||||
newlines - Postfix 3.7's default for the 2023 SMTP-smuggling parameter -
|
||||
one line fixes it: [port25-bare-newline](roadmap.md#port25-bare-newline).
|
||||
|
||||
Everything below is the evidence and the prices. Versions are those of the
|
||||
image (`docs/development.md`, the cited-package table); upstream source is
|
||||
`dev/upstream/`; web sources are linked where a fact came from one.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the image runs, and who maintains it now
|
||||
|
||||
| Component | In the image (bookworm) | In Debian trixie | Upstream |
|
||||
|---|---|---|---|
|
||||
| Debian | 12 bookworm, LTS since 2026-07-12, until 2028-06-30 ([debian.org](https://www.debian.org/News/2026/20260712.en.html)) | 13 trixie, stable since 2025-08-09 | — |
|
||||
| Postfix | `3.7.11-0+deb12u1` (unchanged since the image was pinned; [tracker](https://tracker.debian.org/pkg/postfix)) | `3.10.13-0+deb13u1` (trixie-security) | `3.11.7`, stable branches 3.8–3.11 ([3.11.7](https://postfix.cs.utah.edu/announcements/postfix-3.11.7.html)) |
|
||||
| OpenDKIM | `2.11.0~beta2-8+deb12u1` | `2.11.0~beta2-9.1` (same code; a gcc-14 build fix, a path move, one verifier-side header patch) | `2.11.0-Beta2`, 2018; no release since ([releases](https://github.com/trusteddomainproject/OpenDKIM/releases), [issue #422](https://github.com/trusteddomainproject/OpenDKIM/issues/422), unanswered) |
|
||||
| libmilter | `8.17.1.9-2+deb12u2` | `8.18.1-6` | — |
|
||||
| Cyrus SASL | `2.1.28+dfsg-10` | `2.1.28+dfsg1-9` (same upstream) | — |
|
||||
| supervisor | `4.2.5-1` | `4.2.5-3` (same upstream) | — |
|
||||
| logrotate | `3.21.0-1` | `3.22.0-1` | — |
|
||||
| Go build stage | `golang:1.26-bookworm` | `golang:1.26-trixie` exists (Docker Hub, 2026-10-10) | — |
|
||||
|
||||
**Postfix in bookworm is 2024 code with the 2026 fixes missing.** Upstream
|
||||
issued three security batches this year, all without CVE ids:
|
||||
|
||||
- June 2026: unbounded reads of SMTP commands, responses and BDAT data
|
||||
("large SMTP inputs"); a TLSA-parsing crash (DANE only - not us).
|
||||
- August 2026 ([3.11.6](https://openwall.com/lists/oss-security/2026/08/20/5)):
|
||||
sixteen items, among them a remote memory exhaustion by many small BDAT
|
||||
commands (smtpd, since 3.4), a remote smtpd crash by DATA without MAIL
|
||||
FROM after a BDAT error, a policy bypass after an
|
||||
`smtpd_end_of_data_restrictions` reject (we set none), and the Milter
|
||||
left in "accept" state by the same bug.
|
||||
- September 2026 ([3.11.7](https://postfix.cs.utah.edu/announcements/postfix-3.11.7.html)):
|
||||
two SMTP-smuggling cases behind `smtpd_proxy_filter` (we use none), a
|
||||
crash under `strict_smtputf8` (not set), a multi-line response over-read
|
||||
reachable by a malicious Milter, a `postqueue` use-after-free.
|
||||
|
||||
Legacy releases 3.7.22 and 3.7.23 exist upstream but, as the announcements
|
||||
say, "do not include the patches for out-of-support Postfix versions" of
|
||||
June. Debian's tracker lists one 2026 CVE for bookworm's Postfix
|
||||
([CVE-2026-43964](https://security-tracker.debian.org/tracker/CVE-2026-43964),
|
||||
a one-byte over-read, `no-dsa`) and no bookworm upload at all in 2026; the
|
||||
LTS team works from CVE ids, and these batches have none. The exposed
|
||||
listeners are ours: 465 and 587 to the Internet, 25 when the inbound relay
|
||||
is on.
|
||||
|
||||
**OpenDKIM has three open CVEs and nobody to fix them.** Debian bug
|
||||
[#1149875](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1149875)
|
||||
(2026-10-03, severity important, no reply from maintainer or security team
|
||||
as of 2026-10-15) covers
|
||||
[CVE-2026-100888](https://security-tracker.debian.org/tracker/CVE-2026-100888)
|
||||
(`dkim_canon_selecthdrs`, out-of-bounds write),
|
||||
[CVE-2026-100889](https://security-tracker.debian.org/tracker/CVE-2026-100889)
|
||||
(`dkim_qp_decode`, off-by-one) and
|
||||
[CVE-2026-101277](https://security-tracker.debian.org/tracker/CVE-2026-101277)
|
||||
(`dkim_process_set`, the tag tokenizer); all three "vendor was contacted
|
||||
early but did not respond in any way". Read in `dev/upstream/opendkim-2.11.0~beta2`
|
||||
for our configuration (`Mode s`, attached to 465/587 and to the panel's
|
||||
local submission only; never to port 25):
|
||||
|
||||
- `dkim_qp_decode` is called from `dkim_sig_domainok()`,
|
||||
`dkim_sig_getreportinfo()` and `dkim_sig_getidentity()`
|
||||
(`libopendkim/dkim.c:1718, 7712, 7732, 7801`) - verifier-side functions,
|
||||
reached from `dkim_eoh_verify()`. Not on our path.
|
||||
- `dkim_process_set` runs on every `DKIM-Signature:` header of a message
|
||||
**in either mode**: `dkim_header()` (`dkim.c:6447`, the call at `:6625`)
|
||||
has no mode gate. On our path that header comes only from an authenticated
|
||||
application; the exposure is a tenant's, not the Internet's.
|
||||
- `dkim_canon_selecthdrs` runs when signing as well as when verifying
|
||||
(`dkim_canon_runheaders()`, `dkim-canon.c:1194, 1280`). The write past
|
||||
the array is `lhdrs[shcnt] = NULL` (`dkim-canon.c:1052`) once every
|
||||
header of the message has been matched and a further name follows. When
|
||||
verifying, the names are the signature's `h=`, the attacker's. When
|
||||
signing, OpenDKIM composes the list itself - the headers present that
|
||||
are in `SignHeaders` (by default RFC 6376 § 5.4's SHOULD list,
|
||||
`dkim_should_signhdrs[]`, `dkim.c:223`) plus `OversignHeaders`, our
|
||||
`From` - so the condition is a message whose every header is in that
|
||||
list. Whether Postfix's own `Received:` keeps it out of reach I did not
|
||||
run; it is one ASan run with `miltertest` on the stand to know, and the
|
||||
answer changes only how soon § 4's row is wanted, not whether.
|
||||
|
||||
A patch for each exists in the researchers' write-ups (for `dkim_qp_decode`:
|
||||
[weitongli.com](https://weitongli.com/share/opendkim-qp-off-by-one.html)).
|
||||
Nobody upstream will take them; Debian may yet carry them - the bug is
|
||||
twelve days old - but the project behind the package is gone, and the
|
||||
next finding meets the same silence.
|
||||
|
||||
## 2. The limits met so far, and what would lift each
|
||||
|
||||
| Limit (where it is worked around) | Own patched build | Debian trixie | Signing in the panel |
|
||||
|---|---|---|---|
|
||||
| OpenDKIM logs through syslog(3) only (`internal/syslogrelay`, a supervisord program, `/dev/log` in the entrypoint; audit L30) | yes, a stderr patch | no | yes - the program goes, and the relay with it (nothing else in the container uses syslog(3)) |
|
||||
| The panel reads Postfix's state from `mail.log`, `postconf -h -x`, `postqueue -p`/`-j` | no: these *are* Postfix's interfaces; a patch would invent a private one to maintain | partly: 3.11 adds `postconf -j` (JSON) - not in trixie's 3.10 | no (unchanged) |
|
||||
| The sender check reads `From:` the way OpenDKIM does: `dkim_mail_parse()` ported (`internal/milter/mailparse.go`, 327 lines), the 256-byte `MAXADDRESS` refusal, two diff/probe scripts against the C | no: the port exists because two programs parse the line; a patch does not merge them (OpenDKIM's `SenderMacro` could pick the key by the envelope instead - it changes nothing, since the check already pins `From:` to the login's senders) | no | yes: one parser, ours, and the key chosen by the login's domain, not by `From:` - the class "signed with another tenant's key" is gone by construction; `From:` still has to be the application's (DMARC alignment), with `net/mail` |
|
||||
| The image held to bookworm's versions (Postfix 3.7.11, OpenDKIM 2.11.0~beta2) | yes, at the price in § 3 | yes for Postfix (3.10.13, security-tracked to 2028 and beyond in trixie LTS); OpenDKIM's version is the same everywhere | OpenDKIM's version stops mattering |
|
||||
| A socket file outlives a crashed OpenDKIM (`health/socket.go`, assumptions #38) | yes, with a patch | no | yes - no second milter process |
|
||||
| Keys group-readable, `RequireSafeKeys no`, the `selfpost` group between panel and OpenDKIM | no | no | yes - the panel reads its own keys; the group stays for Postfix's sasldb and maps |
|
||||
| `SIGUSR1` through supervisord to reload the tables, which is what needs `CAP_KILL` in the compose file (cross-uid kill) | no | no | yes - no tables, no signal; `CAP_KILL` can probably go (to verify on the stand: supervisord then signals only root's wrapper and the panel's own uid) |
|
||||
| `smtpd_forbid_bare_newline` defaults to `no` on Postfix < 3.9 (`src/smtpd/smtpd.c:807`); our port 25 does not set it | yes | yes - the default is `normalize` since 3.9 | no (it is Postfix's) - see § 5 |
|
||||
|
||||
Nothing in the Postfix column needs a patch. The limits are OpenDKIM's.
|
||||
|
||||
## 3. The options, priced
|
||||
|
||||
Prices are estimates in stages of the kind the 2026-10-12 audit used (a
|
||||
stage: write, tests, two reviews, e2e, rollout, commit). Nothing here was
|
||||
measured on the stand; where a number matters it says so.
|
||||
|
||||
### A. Patched builds of both, carried by us - **not recommended**
|
||||
|
||||
- *Gives:* the stderr patch for OpenDKIM, the three CVE fixes, any option
|
||||
compiled in or out, the newest Postfix on the day we rebuild.
|
||||
- *Costs, once:* a build stage per program (`apt-get source`, quilt, a
|
||||
patch series, `dpkg-buildpackage`), its build-dependencies, the e2e
|
||||
`checkCitedPackageVersions` rewritten for our version strings, every
|
||||
citation of `3.7.11` (331 lines in 65 files) and `2.11.0~beta2` (55 in
|
||||
14) re-pointed at our tree. Image build time is not measured; from the
|
||||
size of the sources it is minutes, not seconds, on every rebuild.
|
||||
- *Costs, for ever:* Postfix issued seven releases in 2026; each is ours
|
||||
to notice, rebase the series on, rebuild and roll out, with no Debian
|
||||
security team between the announcement and the image. For OpenDKIM there
|
||||
is no upstream to rebase on at all: the series *is* the program, and
|
||||
the next CVE is ours to find and fix. One maintainer, two MTAs.
|
||||
- *Why not:* § 2 shows no Postfix limit a patch lifts, and every OpenDKIM
|
||||
limit lifted by option E without taking OpenDKIM on.
|
||||
|
||||
### B. Unpatched upstream build where the distribution's version is the limit - **not recommended**
|
||||
|
||||
- *Gives:* Postfix 3.11.7 today instead of trixie's 3.10.13 - the
|
||||
September batch two to four weeks earlier (trixie-security carried 3.10.11
|
||||
→ 3.10.12 → 3.10.13 between July and August; 3.10.14 is upstream now).
|
||||
For OpenDKIM there is no newer upstream to build.
|
||||
- *Costs:* A's build stage and watching duty for Postfix, minus the patch
|
||||
series; Debian's own patches (paths, SASL and LMDB build options)
|
||||
become ours to reproduce.
|
||||
- *Why not:* the gap to trixie-security is weeks, the duty is permanent.
|
||||
|
||||
### C. A patch sent upstream instead of carried - **nothing to send**
|
||||
|
||||
- Postfix: no code change is needed (§ 2); Wietse Venema's project is
|
||||
responsive, but we have no patch for it.
|
||||
- OpenDKIM: the project does not answer CVE reporters, the Debian
|
||||
maintainer or its own issue tracker. A patch sent there is a patch
|
||||
carried - option A.
|
||||
|
||||
### D. The base image to Debian trixie - **recommended, next code row** → [base-trixie](roadmap.md#base-trixie)
|
||||
|
||||
- *Gives:* Postfix 3.10.13 with the June and August batches and the
|
||||
September one when trixie-security ships 3.10.14; Debian's security team
|
||||
until 2028, then trixie LTS; `smtpd_forbid_bare_newline=normalize` by
|
||||
default; `smtpd_hide_client_session` (3.10: a `Received:` header without
|
||||
the application's address - a product question of its own, not decided
|
||||
here); OpenSSL 3.5; libmilter 8.18.1.
|
||||
- *Costs:* one stage, one MINOR release. The work is the re-reading: the
|
||||
331 citation lines against the 3.10.13 tree (most will hold word for
|
||||
word; `src/` paths are stable), with attention to what 3.8–3.10 changed
|
||||
on our paths - reject logging now carries `sasl_method`, `sasl_username`,
|
||||
`sasl_sender` (3.10, 20241023: `refusals.go`, `inbound.go` regexes), the
|
||||
queue id or `NOQUEUE` on an abnormal connection end and cleanup's
|
||||
`canceled` line (3.10: new line shapes for the log tailer), `postfix(1)`
|
||||
logging to stderr when it is not a terminal (3.8: the wrapper's output
|
||||
under supervisord), DNS results capped at 100 records and stray CR/LF
|
||||
replaced by default (3.9), the removed parameters (3.9:
|
||||
`permit_naked_ip_address`, `reject_maps_rbl`, `check_relay_domains` - we
|
||||
set none). Then: `dev/upstream` refetched from trixie, the cited-package
|
||||
table and `checkCitedPackageVersions`, the e2e sink image, the build
|
||||
stage tag, `docs/assumptions-2026-10-14.md`'s header, the guide's
|
||||
"what the image contains". OpenDKIM's citations do not change (same
|
||||
upstream code).
|
||||
- *Risk:* a log line we parse changing shape unnoticed - the e2e suite
|
||||
and the fixtures labelled "seen" are the net; a day on the test server
|
||||
before the cut.
|
||||
|
||||
### E. DKIM signing in the panel's own milter; OpenDKIM removed - **recommended, after D** → [panel-dkim-signing](roadmap.md#panel-dkim-signing)
|
||||
|
||||
- *Gives:* every OpenDKIM row of § 2; about 1,500 lines gone (the relay
|
||||
and its tests, the parser port and its tests, the probe scripts, the
|
||||
table renderer and its reload, `opendkim.conf`), two programs fewer
|
||||
under supervisord, two packages fewer in the image, no CVE watch on a
|
||||
dead project, probably `CAP_KILL` dropped. Keys, selectors and the
|
||||
published DNS records are unchanged: the panel already generates the
|
||||
keys (`internal/domain/dkim.go`, PKCS#1 PEM) - nothing for an operator
|
||||
to do.
|
||||
- *How:* `github.com/emersion/go-msgauth/dkim` (v0.7.0, 2025-04, MIT; the
|
||||
author of the `go-milter` we run; the e2e suite already verifies with it).
|
||||
Read for this analysis: `SignOptions` gives `relaxed`/`simple` per side,
|
||||
`HeaderKeys` with over-signing as RFC 6376 § 5.4.2 wants it (a name
|
||||
listed once more than present is kept in `h=` and nothing is hashed for
|
||||
it, `sign.go`), last-instance-first header picking (`header.go`), RSA and
|
||||
Ed25519, and a streaming `NewSigner` (the body is hashed as it passes,
|
||||
not buffered). The milter: headers and body chunks in, `InsertHeader(0,
|
||||
"DKIM-Signature", …)` at end of message - the shape of go-msgauth's own
|
||||
`cmd/dkim-milter`, some 450 lines. The key: by the login's domain on
|
||||
465/587 (`Bindings.DomainOf`, which the sender check has), by the
|
||||
server's name for the panel's local submission (`non_smtpd_milters`
|
||||
pointed at the same socket - strict, as OpenDKIM is today). The header
|
||||
set: `dkim_should_signhdrs[]`, `From` over-signed once; canonicalization
|
||||
`relaxed/simple`, as now.
|
||||
- *Costs:* two to three stages, one MINOR release: the signer and its
|
||||
tests; the removal (code, image, compose, supervisord, entrypoint,
|
||||
Health's liveness set, Resync's steps, docs: architecture § OpenDKIM,
|
||||
security, guide, development's table, assumptions § 3); e2e with an
|
||||
**independent verifier as the oracle** - go-msgauth must not check its
|
||||
own work: `opendkim-testmsg` in the sink image or rspamd's verifier, the
|
||||
way `mailparse-diff.sh` holds the port to the C today - and a message to
|
||||
a real mailbox before the cut.
|
||||
- *Risk:* a signing defect fails DMARC for every tenant at once; the
|
||||
oracle and the canary are the answer. go-msgauth is a small library with
|
||||
a quiet release pace - vendorable if it ever matters.
|
||||
- *Why after D:* D's exposure is remote and unauthenticated (465/587),
|
||||
E's is a tenant's; D is one stage, E is three; neither waits on the
|
||||
other (E re-reads no Postfix citation, D no OpenDKIM one).
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
In this order, each a roadmap row of its own, none of them an own build:
|
||||
|
||||
1. **[port25-bare-newline](roadmap.md#port25-bare-newline)** - one line in
|
||||
`build/postfix-config.sh`, a PATCH release, now (§ 5).
|
||||
2. **[base-trixie](roadmap.md#base-trixie)** - option D, one stage, MINOR.
|
||||
3. **[panel-dkim-signing](roadmap.md#panel-dkim-signing)** - option E, two
|
||||
to three stages, MINOR.
|
||||
|
||||
Options A, B and C are declined with the arguments above; what would bring
|
||||
A back is a limit of Postfix's that its interfaces cannot answer, and none
|
||||
is known.
|
||||
|
||||
## 5. Side finding: bare newlines on port 25
|
||||
|
||||
Postfix 3.7.11 has the 2023 SMTP-smuggling parameter
|
||||
(`smtpd_forbid_bare_newline`, `src/global/mail_params.h:4249`) with the
|
||||
default `no` "for Postfix < 3.9" (`src/smtpd/smtpd.c:807`); its
|
||||
`RELEASE_NOTES` recommend `normalize`, which 3.9 made the default. The
|
||||
inbound relay's listener (`build/postfix-config.sh`, the `smtp/inet`
|
||||
block) does not set it, so with `INBOUND_RELAY_ENABLE=true` the relay is
|
||||
the receiving side the smuggling research described: a bare `<LF>.<LF>`
|
||||
inside DATA ends the message early and what follows is read as the next
|
||||
transaction. Ports 465 and 587 are not the case that matters - the
|
||||
smuggled transaction is still the authenticated session's, under its
|
||||
sender bindings and milters. The fix is
|
||||
`smtp/inet/smtpd_forbid_bare_newline=normalize` with an e2e check on port
|
||||
25 (a bare-LF DATA, one message delivered); a PATCH release. Trixie's 3.10
|
||||
would set it by default, but the relay should not wait for that row.
|
||||
|
||||
A smaller one: `opendkim-tools` is installed in the image and used by
|
||||
nothing at run time (the key is generated in Go; the probe script runs on
|
||||
the server from source). It goes with option E, or with D if E waits.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Plan: delivery-log-storage
|
||||
|
||||
**Status:** shipped in `2.2.0` (2026-10-12); agreed the same day (owner: option 1 of the roadmap item, "store the lines")
|
||||
**Date:** 2026-10-12
|
||||
**Version:** `2.2.0` MINOR (one DDL migration; a 2.1.0 data directory boots unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
A message's page (`/outbound/log/{id}`, mockup `out-message`) shows what
|
||||
Postfix wrote about the message for as long as its row is in the send log.
|
||||
Today the *Delivery log* box is filled by a search of the current `mail.log`
|
||||
on every page view, so it is empty for anything sent before the last daily
|
||||
rotation, while the row itself lives for ninety days.
|
||||
|
||||
## Context (as-built)
|
||||
|
||||
- The log tailer ([internal/logtail](../../internal/logtail/logtail.go))
|
||||
already reads every `mail.log` line once, from a persisted offset, to
|
||||
advance delivery statuses.
|
||||
- `logtail.QueueLines` searches the last 4 MB of the current file for one
|
||||
queue id; `Handlers.deliveryLog` calls it per page view.
|
||||
- A message with several recipients is several send-log rows under one queue
|
||||
id; the page of each shows every line of the message, as the search does
|
||||
today.
|
||||
|
||||
## Decisions
|
||||
|
||||
### What is stored
|
||||
|
||||
A `mail.log` line whose head is a queue id - after the timestamp, the host
|
||||
name and `program[pid]:` - goes into a new table with that id, the line's own
|
||||
time (`lineTime`, the same reading the inbound journal uses) and the line as
|
||||
written, cut to 4096 bytes on a character boundary. The head is matched from
|
||||
the start of the line: the tail is the far end's words. `NOQUEUE` and
|
||||
`warning` are not queue ids - the pattern takes Postfix's short form
|
||||
(hexadecimal) and its long form (no vowels) and nothing else.
|
||||
|
||||
The tailer does not ask whether the id belongs to a send-log row before it
|
||||
writes. The first lines of a message (`client=`, `message-id=`) are logged
|
||||
before the journal milter writes the row at end-of-message, so such a check
|
||||
would drop exactly them.
|
||||
|
||||
**Mail that came in on port 25 is left out** (security review, 2026-10-12). A
|
||||
queue id that `postfix/inbound/smtpd` opened is a stranger's message; it never
|
||||
gets a send-log row, and what a stranger can make Postfix log - his own
|
||||
`message-id`, up to the cut - is not stored to be pruned later. The tailer
|
||||
remembers such ids (4096 at most) until qmgr's `removed` or until another
|
||||
listener opens a message under the id. What is still stored without a row is
|
||||
little: the bounces Postfix generates, and port-25 mail met in mid-flight
|
||||
after a restart.
|
||||
|
||||
Lines are written in one transaction per pass over the log, 500 at most
|
||||
(technical review): a commit per line is a disk sync per line on the one
|
||||
connection the journal milter writes through.
|
||||
|
||||
### Which lines are a row's
|
||||
|
||||
A line is shown on a row's page when the queue id is the same **and** the
|
||||
line's time lies between one hour before the row was written and the queue
|
||||
lifetime (the larger of `maximal_queue_lifetime` and `bounce_queue_lifetime`,
|
||||
as `postconf` reports them) plus a day after it. Postfix reuses a short queue
|
||||
id once the message has left the queue - the id is the microsecond of arrival
|
||||
and the inode of the queue file - so a row that lives ninety days can meet a
|
||||
later message under its id. The window does not rule that out, it limits it
|
||||
to the days the row's own message could still be in the queue; this is the
|
||||
bound the roadmap item asked for. The search of the current file that the
|
||||
page did before had the same exposure for a day. `enable_long_queue_ids`
|
||||
would end it and is a change to the mail path, not part of this item.
|
||||
|
||||
### Retention
|
||||
|
||||
Lines live as long as a send-log row with their queue id does. One rule covers
|
||||
both ends: a line older than an hour whose queue id has no send-log row is
|
||||
deleted. That removes the lines of a row the retention sweep has just deleted,
|
||||
and the lines of messages that never had a row. It runs at start and after
|
||||
every send-log sweep, six-hourly. It asks the send log once per queue id; at a
|
||||
million lines that is still a scan of the table, of the order of a second,
|
||||
during which the one connection is taken.
|
||||
|
||||
There is no cap of its own: with port-25 mail left out, the table is bounded
|
||||
by what authenticated applications send, which level 1 and level 2 limit.
|
||||
|
||||
### After an unclean stop
|
||||
|
||||
The tailer saves its place every few seconds, so after a crash it reads some
|
||||
lines twice. While it is catching up at start a line is written only if the
|
||||
same line is not there already - the rule the inbound journal follows.
|
||||
|
||||
### What is not kept
|
||||
|
||||
- Lines the tailer never reads are not stored: the tail of a file that was
|
||||
rotated away while the panel was down, as with delivery statuses
|
||||
(architecture.md § Log tailer).
|
||||
- A message in the queue across the upgrade to 2.2.0 has only its later lines
|
||||
stored, and the page then shows those alone: the search of the file runs
|
||||
only for a row with nothing stored. The only installation is the owner's
|
||||
test server; no merge of the two sources is built for one moment of it.
|
||||
|
||||
### The page
|
||||
|
||||
The page reads the table: the latest 200 lines of the row, oldest first. When
|
||||
the table has none, it still searches the current `mail.log` as before - that
|
||||
is what shows a message accepted before 2.2.0 while its lines are in today's
|
||||
file. Lines in rotated files are not imported: the only installation is the
|
||||
owner's test server.
|
||||
|
||||
Nothing in the page's layout changes; the `out-message` mockup already draws
|
||||
the box as a `log_pane`. Two texts change, mockup first: the *Outbound log*
|
||||
help topic (it says an old message may show no lines) and the note shown when
|
||||
there are none.
|
||||
|
||||
### Size
|
||||
|
||||
Six to ten lines of about 200 bytes per message: 1-2 KB next to a row of a few
|
||||
hundred bytes. The table becomes the larger part of what the send log costs in
|
||||
`/data` and in a backup; the retention setting bounds both together.
|
||||
|
||||
## Schema (migration `0003_send_log_lines.sql`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE send_log_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id TEXT NOT NULL,
|
||||
logged_at TEXT NOT NULL,
|
||||
line TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_send_log_lines_queue_id ON send_log_lines (queue_id, logged_at);
|
||||
```
|
||||
|
||||
No foreign key: a line is written before its row exists, and one queue id is
|
||||
several rows.
|
||||
|
||||
## Scope
|
||||
|
||||
In: the table, the tailer writing and pruning it, the page reading it, the two
|
||||
texts, docs.
|
||||
|
||||
Out: the inbound journal (it decides for itself, roadmap § delivery-log-storage);
|
||||
importing rotated files; `enable_long_queue_ids`; any change to `mail.log`
|
||||
rotation or to the Server › Mail log page.
|
||||
|
||||
## Security
|
||||
|
||||
- The lines carry the far end's text (replies, HELO names). They are shown
|
||||
today from the file through the same escaping; storing them changes where
|
||||
they are read from, not what is shown or to whom. A domain administrator
|
||||
sees the lines of messages of their own domains, as now.
|
||||
- They now enter the database and so a full backup, which `log/` does not:
|
||||
submitting clients' addresses and SASL logins, message ids, receivers'
|
||||
replies. [security.md](../security.md) says so.
|
||||
- A line is matched from its head, so a reply cannot make itself a line of
|
||||
another message; Postfix does not log raw line breaks, and `postlogd` is the
|
||||
file's only writer.
|
||||
- A stranger on port 25 does not write to the table (above).
|
||||
|
||||
## Done when
|
||||
|
||||
A message sent before the last rotation shows its lines on its page; the lines
|
||||
disappear with the row; the page's wording no longer blames rotation for an
|
||||
ordinary case.
|
||||
|
||||
## Model routing (this plan)
|
||||
|
||||
| Role | Model |
|
||||
|------|-------|
|
||||
| All implementation (code, tests, docs) | **Opus** |
|
||||
| Technical review (reviewer ≠ author: a separate read-only agent) | **Opus** |
|
||||
| Security review | **Fable** |
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
Target version cut: **`2.2.0`** (MINOR). One commit per step;
|
||||
[development.md](../development.md) § Plan checklists.
|
||||
|
||||
- [x] Migration `0003_send_log_lines.sql`, store methods (insert, insert once, lines of a row, prune) and their tests — **Opus**
|
||||
- [x] Log tailer: store queue-id lines, write once while catching up, prune with the send-log sweep — **Opus** (hourly as first built; six-hourly after the reviews)
|
||||
- [x] The two texts, mockup first: the *Outbound log* help topic and the empty note (`design:` commit) — **Opus** (the help topic is in the mockups; the empty note is not drawn there - the `out-message` mockup shows a message with lines - and changes with the page)
|
||||
- [x] Message page reads the table, falls back to the current `mail.log`; help text in `help.go` — **Opus**
|
||||
- [x] [architecture.md](../architecture.md), [guide.md](../guide.md), [schema-migrations.md](../schema-migrations.md) — **Opus** (schema-migrations.md went in with the migration)
|
||||
- [x] `go vet`, `go test`, e2e: a message's page still lists its lines after a forced rotation — **Opus** (on the test server: vet ok, 22 packages ok, e2e ok with the new step `delivery_log_survives_rotation`)
|
||||
- [x] Technical review — **Opus** (2026-10-12; no Critical or High. Fixed: one transaction per pass instead of a commit per line, the prune asks the send log once per queue id and runs six-hourly, the cut keeps characters whole, tests for what the comments claimed. Left as found and written down under *What is not kept*: lines the tailer never read, and a message in the queue across the upgrade)
|
||||
- [x] Security review — **Fable** (2026-10-12; no Critical or High. Fixed: port-25 mail is not stored, so a stranger cannot grow the table; [security.md](../security.md) states what a backup now carries. The window against queue-id reuse is described as the limit it is)
|
||||
@@ -0,0 +1,260 @@
|
||||
# Plan: from-header-binding
|
||||
|
||||
**Status:** shipped in `2.4.0` (2026-10-12); agreed the same day - the design questions were closed with the owner one at a time and he took it into work ("Да")
|
||||
**Date:** 2026-10-12
|
||||
**Version:** `2.4.0` MINOR (one migration; it refuses mail that was accepted before).
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
An application sends only as itself - in the `From:` line a mail program
|
||||
shows and OpenDKIM chooses the key by, not only in the envelope.
|
||||
|
||||
Until now Postfix held the envelope sender against the SASL login and nothing
|
||||
held the header. An application of one sending domain could write another
|
||||
registered domain, or the server's own name, into `From:` and have the message
|
||||
signed with that name's key (security review of panel-notifications,
|
||||
2026-10-12).
|
||||
|
||||
## Decisions (owner, 2026-10-12)
|
||||
|
||||
### The rule
|
||||
|
||||
**`From:` is held to what the envelope is held to.** An application set to
|
||||
any address of its domain may write any address of that domain; one with a
|
||||
list of addresses, only those. Anything else is refused: another registered
|
||||
domain, the server's name, an address at a domain this server knows nothing
|
||||
of, no `From:` at all.
|
||||
|
||||
I proposed the narrower rule - refuse only a `From:` that names a domain this
|
||||
server signs for, and let a stranger's address through unsigned as before -
|
||||
and, for an application with a list, to check the domain alone. The owner
|
||||
chose the wider one both times. What it stops working: a form that puts its
|
||||
visitor's address into `From:`, and an application with a list whose envelope
|
||||
address and header address differ, until the second is added to the list.
|
||||
|
||||
When the hostname is itself a sending domain its applications write
|
||||
`…@<hostname>` by right, as the guide already says.
|
||||
|
||||
### Where it is checked, and what happens when it cannot be
|
||||
|
||||
**The panel checks, and the check is strict:** while the panel does not
|
||||
answer, Postfix replies with a temporary error and accepts nothing unchecked.
|
||||
|
||||
I first proposed the opposite (fail-open, as the journal and the level-2 limit
|
||||
are) and then a small process of its own. The owner's objection to the first:
|
||||
a check that is absent while the panel is down does not close the hole, and
|
||||
the panel only has to be slow - 15 seconds - for Postfix to go on without it.
|
||||
His answer to the second: not yet; measure first.
|
||||
|
||||
So the check is a role of the panel process on a socket of its own, attached
|
||||
with `default_action=tempfail`. The journal milter keeps its socket and stays
|
||||
fail-open: a slow database still does not defer mail. The check reads no
|
||||
database - it reads the file of sender bindings Postfix itself reads for the
|
||||
envelope, so the two cannot disagree - and moving it into a process of its own
|
||||
later is a change of where it is started.
|
||||
|
||||
### Statistics, to decide whether the check needs its own process
|
||||
|
||||
Per day, kept a year:
|
||||
|
||||
- **for how long the panel did not answer while Postfix was running.** The
|
||||
panel marks itself alive every few seconds; at start the gap since the last
|
||||
mark is its downtime, counted when Postfix was not restarted within it (a
|
||||
rollout or a reboot stops mail for another reason);
|
||||
- **how many messages were deferred because the check did not answer**, read
|
||||
from `mail.log`. This one also sees a panel that is running and not
|
||||
answering, which the mark does not.
|
||||
|
||||
Shown on **Server › Health** in a card of its own, *Sender check*: today, the
|
||||
last 30 days, the last 12 months. The card warns when a message was deferred
|
||||
in the last 24 hours; downtime that deferred nothing warns of nothing.
|
||||
|
||||
### What a refusal looks like
|
||||
|
||||
- **To the application:** `550 5.7.1`, permanent, naming the address. A
|
||||
level-2 limit answers 4xx because the message will pass in an hour; a wrong
|
||||
`From:` will not, and a 4xx would have the application send it again for
|
||||
days.
|
||||
- **In the Outbound log:** a row with the reason in the status tag -
|
||||
`wrong From`, beside `rate-limited` and the tag of a refused client address.
|
||||
The built panel wrote `rejected` for all of them; it follows the mockup now.
|
||||
(The mockup's status filter is not built - the log filters by domain and
|
||||
application - so there is nothing to keep in step with the tags.)
|
||||
- **On the message's page:** a fact *From: line* with the address written in
|
||||
the header, and a History step that says which address was refused, for
|
||||
which application, what that application may write, and that it was told
|
||||
`550 5.7.1`.
|
||||
|
||||
### Health, re-laid
|
||||
|
||||
The owner on the first picture of the new card: too many holes on the screen,
|
||||
the numbers should follow importance, and the boxes should read left to right
|
||||
and top to bottom - with stacked columns the third box was below the fold and
|
||||
the fourth was not.
|
||||
|
||||
- Order: 01 Processes, 02 Milter sockets, 03 Sender check, 04 TLS
|
||||
certificate, 05 Hostname and reverse DNS, 06 Notification mail, 07 Machine,
|
||||
08 Configuration.
|
||||
- Layout `cards` (new, on the components page): two stacks, odd numbers on the
|
||||
left and even on the right, each closed up, so a short box leaves no gap
|
||||
beside a tall one and the eye still goes 01, 02, 03. On a phone the boxes
|
||||
come in the order of their numbers.
|
||||
|
||||
## Settled while building
|
||||
|
||||
- **The header is read twice.** One `From:` field holding one address, by
|
||||
Go's parser; then every `@name` of the raw value is held against OpenDKIM's
|
||||
own `SigningTable`. The first build took the names for the second look from
|
||||
the sender bindings, and the e2e stand let a line through that named a
|
||||
domain with a key and no application yet: a domain is a signing name from
|
||||
the moment it is added, not from its first application.
|
||||
- **What Postfix writes when the check does not answer** (seen on the stand):
|
||||
a warning naming the socket, then `milter-reject: CONNECT … 451 4.7.1
|
||||
Service unavailable` and the same for `EHLO`; the client is told `454 4.3.0
|
||||
Try again later` at `AUTH` and leaves. No `MAIL` is ever offered, so what
|
||||
is counted is the connection turned away, once. The journal milter's
|
||||
level-2 refusal is written in the same words, which is why a reject line
|
||||
counts only after the warning.
|
||||
- **When Postfix started** is asked of supervisord (`supervisorctl status`,
|
||||
the uptime of `postfix`).
|
||||
- **The reply** is a milter reply code, `550 5.7.1 From: <line> - <why>`; what
|
||||
the client wrote is reduced to printable ASCII and cut first.
|
||||
- **The Delivery log box stays on a refused message's page**, with the one
|
||||
sentence it already had for a message that never reached the queue; the
|
||||
picture shown to the owner had left the box out.
|
||||
|
||||
## To settle while building (as first written)
|
||||
|
||||
- How the header is read so that the check and OpenDKIM cannot disagree: one
|
||||
`From:` field, one address, and a cruder second look at the raw value.
|
||||
- What Postfix writes in `mail.log` when a strict milter does not answer, and
|
||||
how a line is told to be this milter's and not OpenDKIM's.
|
||||
- How the panel learns when Postfix started.
|
||||
- Schema: the reason of a refusal and the header address on a send-log row;
|
||||
the table of days.
|
||||
- That a refusal for `From:` is not taken for a refusal under a limit by the
|
||||
notifier.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Mockups: Health card and layout, the log row, help topic; accepted by the owner 2026-10-12 (`design:` commit)
|
||||
- [x] Migration and store: refusal reason and header address, days of the sender check (`0006_sender_check.sql`)
|
||||
- [x] The check: a role of the panel on its own socket, bindings read from Postfix's file; Postfix attaches it strictly; start order
|
||||
- [x] Statistics: the alive mark and downtime, deferred mail from `mail.log`
|
||||
- [x] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
|
||||
- [x] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
|
||||
- [x] `go vet`, `go test`, e2e: refused for another domain's `From:`, for the hostname's and for an address off the list; accepted for its own (on the test server: vet ok, 23 packages ok, e2e ok - `sender_check_holds_the_from_line`, `panel_down_defers_mail`)
|
||||
- [x] Technical review — **Opus** (2026-10-12; one Critical, fixed: a folded `From:` line - what a mail library makes of a long display name - was refused for good. Also fixed: a display name in a charset Go does not convert was refused; the statistics ran before the check served, and a hung `supervisorctl` would have kept it from serving; the count of deferred mail missed a check that stops answering in the middle of a message, and is now taken at Postfix's warning; a card whose figures could not be read said ok; a killed container's stale socket satisfied the wrapper's wait; outside quoted strings the line may now hold one `@` only. Three were first left as found and fixed the same evening at the owner's question: downtime is cut at the UTC midnights it crossed; the row of a message with the null envelope sender takes the domain the login is bound to; a log read again after an unclean stop does not count again what it counted before)
|
||||
- [x] Security review — **Fable** (2026-10-12; no Critical or High. The same folded line, as its one Medium. Fixed: another sender's name in an encoded display name passed the second look; a client could arm the deferred counter by writing the socket's name where Postfix quotes it in a warning; a refused message wrote a row per recipient, a thousand at most - now one. Recorded: what a display name looks like to a person is not promised. The review also found the check stricter than Postfix for an address with an extension (`invoices+x@`, allowed in the envelope and refused in the header); the owner asked for it to work, and the check now makes Postfix's second try, with `recipient_delimiter` set explicitly. Confirmed from the code: no path to the signer around the check, and every failure of the check defers)
|
||||
|
||||
## Not done
|
||||
|
||||
Written at the owner's word when he had the version cut ("Запиши все
|
||||
недоделки в план", 2026-10-12). Nothing here was promised and dropped in
|
||||
silence; each line says what is missing and what it would take.
|
||||
|
||||
### Decided to wait
|
||||
|
||||
- **A process of its own for the check.** The owner: measure first. Server ›
|
||||
Health now has the numbers; the decision is his when they have had time to
|
||||
say something. Moving the role out is a change of where it is started -
|
||||
a subcommand of the panel binary and a `[program:]` in supervisord - and
|
||||
the downtime figure then needs a new meaning.
|
||||
|
||||
### Shown to the owner one way and built another
|
||||
|
||||
- **The Delivery log box is on a refused message's page.** The picture he
|
||||
approved had left it out; the built page keeps it, with one sentence. And
|
||||
the sentence is less than the truth for a `From:` refusal: cleanup did
|
||||
write a line about it under a queue id (`milter-reject: END-OF-MESSAGE ...
|
||||
5.7.1 From: ...`), but the check answers before the queue id reaches it, so
|
||||
the row keeps none and the page cannot show that line. **Decided
|
||||
2026-10-13 (owner):** the box is required - he had overlooked its absence
|
||||
from the picture, it was never a decision to drop it. The tailer attaches
|
||||
the line to the row, for all three kinds of refusal; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
|
||||
- **The Outbound log has no status filter.** The mockup draws one; the built
|
||||
log filters by domain and application. With three kinds of refusal now
|
||||
told apart by tag, a filter by status and reason would be worth having.
|
||||
**Decided 2026-10-13 (owner):** a filter by status, without the split by
|
||||
reason, and a filter by day as the Inbound log has; a step of [audit-2026-10-12](../audit-2026-10-12.md) stage 4.
|
||||
- **"Messages deferred" counts connections Postfix turned away.** Postfix
|
||||
refuses from the connection on, before a message is offered, so a monitor
|
||||
that only connects during an outage is counted. The guide says so; the
|
||||
card's column head and the help topic still say "messages". **Decided
|
||||
2026-10-13 (owner):** the column is renamed *Connections turned away*; a
|
||||
step of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
|
||||
|
||||
### Known and small
|
||||
|
||||
- **"Today" on the card is the UTC day**, and the card does not say so.
|
||||
**Decided 2026-10-13 (owner):** not a label - the local day; it became the
|
||||
roadmap item `local-time`, for the whole panel.
|
||||
- **A deferral in the very second of the last one counted before an unclean
|
||||
stop is passed over** at the restart: `mail.log` stamps whole seconds.
|
||||
Chosen over counting a stretch of the log twice. **Decided 2026-10-14
|
||||
(owner):** counted exactly - the counter keeps a place in the log, saved
|
||||
with the count; a step of [audit-2026-10-12](../audit-2026-10-12.md)
|
||||
stage 2b.
|
||||
- **Deferrals are lost when `mail.log` is rotated while the panel is down**:
|
||||
the tailer starts on the new file. **Decided 2026-10-13 (owner):** the
|
||||
tailer reads the rotated file to its end first - not the deferrals alone,
|
||||
everything the panel takes from the log; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
|
||||
- **A start at which `supervisorctl` fails counts no downtime**; it is
|
||||
logged. **Decided 2026-10-13 (owner):** the question is repeated, and
|
||||
without an answer the downtime is counted from the alive mark and the day
|
||||
marked as estimated; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
|
||||
- **Logins are compared as written**, Postfix compares them without regard
|
||||
to case. The panel writes logins one way, so nothing differs today.
|
||||
- **The phone order of the `cards` layout is written for four boxes to a
|
||||
stack** and takes every child of a stack for a box (said at the partial).
|
||||
The rule the technical review suggested instead - the order from the
|
||||
box's own number - changes the component and would be drawn first.
|
||||
**Decided 2026-10-13 (owner):** that rule is built; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
|
||||
- **Health's socket check looks at the file, not at who listens**: a socket
|
||||
nobody answers on would read "ok". The start now removes stale sockets,
|
||||
which was the one way to get such a file. **Decided 2026-10-13 (owner):**
|
||||
the check connects to the socket; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
|
||||
- **The kit page's table of status tags does not list the new ones**
|
||||
(`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey
|
||||
beside two red tags - as the mockup has it, not as a decision.
|
||||
- **Refusal rows still in the buffer when the panel stops are not written**;
|
||||
the refusals themselves were answered. **Decided 2026-10-13 (owner):**
|
||||
the tailer writes such a row from the refusal's line in `mail.log`; a step
|
||||
of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
|
||||
|
||||
### Accepted, in security.md
|
||||
|
||||
- **The panel's downtime is mail's.** The price of the strict check.
|
||||
- **What a display name looks like to a person is not promised.** A name of
|
||||
another sender written without an `@` the scan can find passes, signed
|
||||
with the application's own key.
|
||||
- **A malformed packet on the check's socket ends the panel** (the milter
|
||||
library panics on an empty one). The socket is reachable only inside the
|
||||
container, by the group Postfix is in; the journal milter always had this,
|
||||
and what is new is that the price is deferred mail.
|
||||
|
||||
### Not verified
|
||||
|
||||
- **OpenDKIM's reading of a `From:` line** was reasoned about, not run: its
|
||||
parser is not in this repository. The two rules that do not depend on it -
|
||||
no other signing name after any `@`, one `@` outside quotes - are what the
|
||||
claim rests on. A test that feeds the stand's OpenDKIM the lines the check
|
||||
passes and reads which key it signs with would settle it. **Decided
|
||||
2026-10-13 (owner):** that test is built; a step of
|
||||
[audit-2026-10-12](../audit-2026-10-12.md) stage 2.
|
||||
- **The pages were not looked at in a browser on the deployed panel**: I
|
||||
cannot sign in there. They were rendered from the fixtures and
|
||||
screenshotted, the polling and the phone order read from the code and
|
||||
exercised by e2e only as far as the Health numbers.
|
||||
- **No refusal has been seen on the test server itself**, only on the e2e
|
||||
stand: there is no application there I can send as.
|
||||
- **A check that stops answering in the middle of a message** is counted
|
||||
from a warning composed from Postfix's source; the stand only produced the
|
||||
case of nobody listening.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan: inbound-antispam-panel
|
||||
|
||||
**Status:** agreed
|
||||
**Status:** shipped in `2.1.0` (2026-10-12)
|
||||
**Date:** 2026-08-19
|
||||
**Version:** `2.1.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths). Was `1.10.0`; on 2026-10-09 the owner put the panel redesign first, so this ships on the 2.0 baseline and its screens are built from the redesign's component kit ([panel-redesign.md](panel-redesign.md) § Component kit).
|
||||
|
||||
@@ -34,8 +34,8 @@ Lists let the operator correct false positives/negatives (e.g. always allow
|
||||
|
||||
## Quarantine
|
||||
|
||||
**Not in this plan.** Held-mail review/release is tracked separately as roadmap
|
||||
candidate [inbound-quarantine](inbound-quarantine.md). The journal may still
|
||||
**Not in this plan.** Held-mail review/release is an idea kept outside the roadmap,
|
||||
[inbound-quarantine](inbound-quarantine.md). The journal may still
|
||||
record a `quarantine` **decision label** when the external filter reports it,
|
||||
without SelfPost storing the message.
|
||||
|
||||
@@ -57,8 +57,14 @@ smtpd_milters = { antispam, inbound-journal }
|
||||
```
|
||||
|
||||
Antispam runs first; inbound-journal runs at end-of-message on the **accept**
|
||||
path and reads rspamd-added headers (`X-Rspamd-Action`, `X-Rspamd-Score`, symbol
|
||||
summary when present).
|
||||
path and reads rspamd-added headers (`X-Rspamd-Action`, and `X-Spamd-Result`
|
||||
for the score and symbols - rspamd has no `X-Rspamd-Score`; the sidecar adds
|
||||
both through `deploy/antispam/rspamd/milter_headers.conf`). As built they are
|
||||
read only next to rspamd's signature of the message, `X-SelfPost-Filter`
|
||||
(owner, 2026-10-12: "А rspamd не может дописывать какой-то заголовок, по
|
||||
которому мы будем знать, что это заголовки нашего спам фильтра?") - an HMAC
|
||||
of the queue id, since a fixed mark could be written by a sender as easily as
|
||||
the verdict itself.
|
||||
|
||||
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also
|
||||
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
|
||||
@@ -73,7 +79,9 @@ antispam hook). Without an external filter:
|
||||
|
||||
- **accept** rows — message relayed (engine `selfpost`, detail empty);
|
||||
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
|
||||
size limit, unknown recipient, etc.).
|
||||
unknown recipient, a sender domain that does not exist. As built, a refusal
|
||||
whose log line names no recipient - the size limit answered at MAIL FROM -
|
||||
and the per-client rate limit, which Postfix logs as a warning, make no row).
|
||||
|
||||
Panel copy explains that rspamd symbols appear only when the antispam milter is
|
||||
configured.
|
||||
@@ -83,6 +91,25 @@ configured.
|
||||
**Global administrator only** for journal and lists — same as inbound relay. No
|
||||
domain-admin access in v1.
|
||||
|
||||
*As built (2026-10-11):* the lists are the global role's; the **log follows
|
||||
the delegation of Inbound** that 2.0 introduced after this was written - a
|
||||
user with the `domain` role sees the rows of the inbound domains assigned to
|
||||
them and nothing else, without the *Allow / Deny sender* links. That is what
|
||||
the accepted `in-log` mockup draws (no "global role" in its head). The owner
|
||||
was told and can turn it back into a global-only page.
|
||||
|
||||
**Inbound › Log accepted by the owner on 2026-10-12** ("Этот экран
|
||||
утверждаем"), from screenshots of the page built from its fixture, after his
|
||||
changes to it: the sender column is headed *From*; *Why* is not a column but
|
||||
a sheet over the page, opened by the decision tag, holding a centred table of
|
||||
who decided, the verdict and symbols or the reply, and the subject whole
|
||||
(kept on the sheet at his word: the table clips it); a message Postfix itself
|
||||
refused offers no *Deny sender*. Inbound › Filter lists was redrawn at his word
|
||||
the same day - kind in a column of its own, the note on the entry's sheet,
|
||||
matches counted (§ Filter list usage) - and **accepted by him on 2026-10-12**
|
||||
("Да"), after a deny entry he added in the panel refused a test message with
|
||||
the list's reply and the journal recorded it.
|
||||
|
||||
### Retention
|
||||
|
||||
Mirror send-log retention:
|
||||
@@ -101,13 +128,53 @@ Mirror send-log retention:
|
||||
| Entry types | sender address, sender domain (`@domain` suffix), client IP / CIDR |
|
||||
| Precedence | **deny overrides allow** |
|
||||
| Scope | instance-wide (not per inbound domain in v1) |
|
||||
| Where applied | **rspamd map files** under `/data/antispam/` (allow.map, deny.map); documented compose fragment mounts them into the sidecar |
|
||||
| Where applied | **rspamd map files** under `/data/antispam/`, one per list and entry type (`allow_ip.map`, `allow_from.map`, `allow_domain.map`, `deny_…`) - an IP map and a sender map are different map types to rspamd; documented compose fragment mounts them into the sidecar. Deny is a prefilter reject, allow a weight of -100 (as built: two prefilters do not give "deny overrides allow") |
|
||||
| SelfPost pre-check | **out of v1** — lists live in rspamd only |
|
||||
| Edit access | global administrator only |
|
||||
| When inactive | lists editable in the panel but UI warns that sync applies only when `INBOUND_ANTISPAM_MILTER` is set; `postfix reload` / rspamd reload documented in guide |
|
||||
|
||||
List change → atomic map rewrite (same pattern as Postfix maps) → rspamd reload
|
||||
via documented operator step or sidecar `SIGHUP` in the compose fragment.
|
||||
List change → atomic map rewrite (same pattern as Postfix maps). No reload:
|
||||
rspamd rereads a changed map file by itself (about ten seconds on 3.11).
|
||||
|
||||
### Filter list usage (owner, 2026-10-12)
|
||||
|
||||
Each entry shows how often it decided a message (*Matched*, or *never*) and,
|
||||
on its sheet, when it last did - so that entries nothing matches any more can
|
||||
be removed. **The count is read off the journal on every view of the page;
|
||||
nothing is stored for it.** A journal row counts for an entry when the list
|
||||
decided the message - a rejected row whose reply is the deny rule's message,
|
||||
an accepted row whose verdict carries the allow rule's symbol
|
||||
(`SELFPOST_ALLOW_IP` / `_FROM` / `_DOMAIN`, which the journal milter always
|
||||
keeps) - and the entry fits the row's sender or client address the way rspamd
|
||||
matches it (`inbound.Lists.Usage`).
|
||||
|
||||
What follows from counting this way, and is accepted:
|
||||
|
||||
- the count reaches back as far as the journal does: its retention (90 days by
|
||||
default), which is what the sheet says;
|
||||
- a row counts for every fitting entry of the deciding list, since the journal
|
||||
does not say which of two the filter met first;
|
||||
- a message refused at end-of-message is one row whatever its recipient count,
|
||||
so a count is of messages, roughly.
|
||||
|
||||
**When to move to a counter kept with each entry** (a migration adding hits
|
||||
and last-hit to `inbound_spam_list`, bumped where the journal row is written;
|
||||
the owner asked for the condition to be written down). Any one of:
|
||||
|
||||
1. **The journal is cut by its 10 000-row cap and not by age.** Then it holds
|
||||
fewer days than its retention, the sheet's "in the last 90 days" is no
|
||||
longer true, and *never* stops being evidence that an entry is stale. The
|
||||
sign: the log tailer's prune line keeps reporting rows removed while the
|
||||
oldest row of Inbound › Log is younger than the retention.
|
||||
2. **The retention is set short** (towards its minimum of 7 days) for reasons
|
||||
of its own, privacy or disk. A week without a match does not make an entry
|
||||
stale.
|
||||
3. **Counts are wanted that outlive the journal** - since the entry was added.
|
||||
4. **The page is slow.** A view reads every journal row a list decided and
|
||||
tries it against every entry. With the cap that is at most 10 000 rows
|
||||
times the number of entries; past a few hundred entries, measure it.
|
||||
|
||||
Until then no migration is spent on it.
|
||||
|
||||
### Activation
|
||||
|
||||
@@ -200,7 +267,9 @@ flowchart LR
|
||||
## Security
|
||||
|
||||
- Same validation whitelists as inbound relay ([security.md](../security.md)).
|
||||
- Journal rows contain PII — retention and global-admin-only access.
|
||||
- Journal rows contain PII — retention, and access by reach: the global role
|
||||
reads all of it, a `domain` user the rows of assigned inbound domains (as
|
||||
built; see § RBAC).
|
||||
- Inbound journal milter fail-open must not block mail (backup-MX role).
|
||||
- List map writes injection-safe; deny wins over allow.
|
||||
|
||||
@@ -243,14 +312,14 @@ Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
|
||||
[development.md](../development.md) § Plan checklists.
|
||||
|
||||
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
|
||||
- [ ] Migration `0002_inbound_spam_log.sql` on the 2.0 baseline — **Composer**
|
||||
- [ ] Inbound journal-milter + Postfix wiring — **Composer**
|
||||
- [ ] mail.log tailer: inbound reject rows — **Composer**
|
||||
- [ ] List CRUD + validation + atomic rspamd map sync — **Composer**
|
||||
- [ ] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
|
||||
- [ ] Backup includes `/data/antispam/` — **Composer**
|
||||
- [ ] Unit + handler tests — **Composer**
|
||||
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
|
||||
- [ ] `go vet`, `go test`, e2e if applicable — **Composer**
|
||||
- [ ] Technical review of inbound antispam changes — **Opus**
|
||||
- [ ] Security review inbound antispam path — **Fable**
|
||||
- [x] Migration `0002_inbound_spam_log.sql` on the 2.0 baseline — **Composer**
|
||||
- [x] Inbound journal-milter + Postfix wiring — **Composer**
|
||||
- [x] mail.log tailer: inbound reject rows — **Composer**
|
||||
- [x] List CRUD + validation + atomic rspamd map sync — **Composer**
|
||||
- [x] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
|
||||
- [x] Backup includes `/data/antispam/` — **Composer** (it always did: a backup walks all of `/data`; now held by a test, and the guide says the `antispam/` fragment directory is not in it)
|
||||
- [x] Unit + handler tests — **Composer** (written with each step: store, milter, tailer, lists and their usage, view fixtures held to the outlines, handlers for both roles)
|
||||
- [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review)
|
||||
- [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel)
|
||||
- [x] Technical review of inbound antispam changes — **Opus** (2026-10-12; findings fixed: client address of a connection's later messages, the lists' writer under a lock, the tailer's queue-id memory, hold rows written twice, the cap by time; and, outside the feature, the level-2 limit and client-address list skipped after a connection's first message)
|
||||
- [x] Security review inbound antispam path — **Fable** (2026-10-12; no Critical or High; HELO-forged journal rows and the six-hourly row cap fixed; a sender's own verdict while the filter is down closed by the filter's signature, the owner's idea; accepted risks recorded in [security.md](../security.md))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan: inbound-quarantine
|
||||
|
||||
**Status:** candidate — design TBD; needs explicit agreement before any code
|
||||
**Status:** an idea to think about, not on the roadmap (owner, 2026-10-12) — design TBD; needs explicit agreement before any code
|
||||
**Version:** TBD (`1.x` MINOR if opt-in and backward-compatible; may warrant `2.x`
|
||||
if it changes inbound storage or backup contract).
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Plan: panel-dkim-signing
|
||||
|
||||
**Status:** agreed, 2026-10-15 - the row is agreed (owner, 2026-10-15, the
|
||||
order port25-bare-newline -> base-trixie -> panel-dkim-signing); three
|
||||
questions below were settled one at a time the same day.
|
||||
**Date:** 2026-10-15
|
||||
**Version:** `2.8.0` MINOR (after base-trixie, shipped `2.7.0`). No migration;
|
||||
nothing for an operator to do.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
The panel signs outgoing mail itself, in its own milter, and OpenDKIM
|
||||
leaves the image. The analysis ([own-mta-builds](../own-mta-builds-2026-10-15.md)
|
||||
§ 3 E) and the roadmap section give the reasons: OpenDKIM is a 2018 beta
|
||||
with three open 2026 CVEs and nobody upstream; every OpenDKIM limit
|
||||
SelfPost works around goes with it (the syslog relay, the From: parser
|
||||
port and its 256-byte refusal, the tables reloaded by a cross-uid signal
|
||||
and `CAP_KILL`, keys shared through a group).
|
||||
|
||||
What stays the same for an operator: the keys, the selectors, the DNS
|
||||
records, the panel's pages (one label on Health excepted, Q3).
|
||||
|
||||
## What the signer does
|
||||
|
||||
- **Library:** `github.com/emersion/go-msgauth/dkim` v0.7.0 (MIT; the
|
||||
author of the go-milter v0.4.1 we run; the e2e suite verifies with its
|
||||
v0.6.8 today). Read for this plan, `dkim/sign.go`: `NewSigner` hashes the
|
||||
body as it is written (an `io.Pipe` into a goroutine), so a message is
|
||||
not buffered; `HeaderKeys` is written to `h=` in the order given, each
|
||||
name picked last instance first (`header.go`, `newHeaderPicker`), and a
|
||||
name listed once more than it occurs is kept in `h=` with nothing hashed
|
||||
for it - the over-signing RFC 6376 § 5.4.2 describes; RSA-SHA256; the
|
||||
signature is the caller's to insert.
|
||||
- **What is signed:** `relaxed/simple`, as `build/opendkim.conf` signs
|
||||
today. `h=`: every header of the message whose name is in OpenDKIM's
|
||||
`dkim_should_signhdrs[]` (`libopendkim/dkim.c:223-246`: from, reply-to,
|
||||
subject, date, to, cc, the five resent-*, in-reply-to, references, the
|
||||
seven list-*), each occurrence, in message order, plus `From` once more
|
||||
(`OversignHeaders From` today). Absent names are not listed, as
|
||||
OpenDKIM does not list them.
|
||||
- **Which key:** on 465/587 the key of the domain the SASL login belongs
|
||||
to - `{auth_authen}`, then `Bindings.DomainOf(login)`, the table the
|
||||
sender check already reads (`internal/milter/sender.go:262-269`). For the
|
||||
panel's own notifications (`sendmail(1)`, `non_smtpd_milters`) the key of
|
||||
the server's name, as `EnsureServerSigned` provides it today. The sender
|
||||
check has already held `From:` to that same domain, so the signature's
|
||||
`d=` is the `From:` domain DMARC aligns on.
|
||||
- **What Postfix hands a milter,** read in Postfix 3.10.13,
|
||||
`src/milter/milter8.c:2329-2400`, `milter8_header()`: Postfix's own
|
||||
`Received:` is not shown (and an inserted header at index 0 lands above
|
||||
it, "relative to the message as delivered"); a multi-line header comes
|
||||
as text separated by `\n`; one space after the colon is eaten unless the
|
||||
milter asks for `SMFIP_HDR_LEADSPC` (`OptHeaderLeadingSpace`, which the
|
||||
sender check already asks for); body records end in `\r\n`
|
||||
(`milter8_body()`, :2470-2500). The signer rebuilds each header as
|
||||
`Name:` + value with `\n` made `\r\n` - go-msgauth's own
|
||||
`cmd/dkim-milter` writes `name + ": " + value + "\r\n"` and would sign a
|
||||
folded header wrongly. A unit test signs folded, tab-led and
|
||||
space-free headers and an independent verifier checks them.
|
||||
- **When it cannot sign** (no key file, a login with no domain, a read
|
||||
error): temporary failure, `default_action=tempfail`, as OpenDKIM is
|
||||
attached today. A message is never sent unsigned by accident.
|
||||
- **8-bit mail:** `disable_mime_output_conversion=yes`. Postfix converts an
|
||||
8-bit message to 7-bit for a server that does not announce 8BITMIME
|
||||
(default `no`, `src/global/mail_params.h:3088`), which breaks any body
|
||||
hash - OpenDKIM's today as well. Not set until now; set with this row.
|
||||
|
||||
## Decisions settled before this plan
|
||||
|
||||
From the roadmap section and the analysis, agreed with the row: the
|
||||
library; `relaxed/simple`; the header set and `From` over-signed; the key
|
||||
by the login's domain and by the server's name for local mail; strict;
|
||||
the removals below; keys, selectors and records unchanged; the e2e oracle
|
||||
an independent verifier; a message to a real mailbox before the cut.
|
||||
|
||||
Decided here (technical, the owner may overrule):
|
||||
|
||||
- **The oracle is dkimpy** (`python3-dkim` 1.1.8-2 in trixie,
|
||||
`/usr/bin/dkimverify`, seen in `debian:trixie` 2026-10-15) - a separate
|
||||
implementation in another language, maintained, packaged by Debian; not
|
||||
OpenDKIM's `opendkim-testmsg` (the code this row removes) and not
|
||||
go-msgauth (the signer's own library). It runs in the e2e sink's
|
||||
network against the stand's CoreDNS.
|
||||
- **A cross-check before OpenDKIM goes:** on the stand of stage 1 both
|
||||
sign the same messages for a while (OpenDKIM after the panel in the
|
||||
chain) and the two signatures' `bh=` and `h=` are compared on every
|
||||
e2e message; they must agree before stage 2 removes OpenDKIM.
|
||||
- **The From: parser port goes.** It exists so that the sender check
|
||||
judges the address OpenDKIM chooses the key by. With the key chosen by
|
||||
the login, the check keeps `net/mail` (one RFC 5322 address at an
|
||||
allowed domain) and the 256-byte refusal goes; the refusal texts that
|
||||
name OpenDKIM are rewritten.
|
||||
|
||||
## Open questions (to the owner, one at a time)
|
||||
|
||||
- **Q1. Where the signer runs.** (a) Its own role in the panel process on
|
||||
two sockets of its own: `sign.sock` for 465/587 (the key by the login)
|
||||
and `sign-local.sock` for local submission (the server's key only), in
|
||||
the chain where OpenDKIM is now: sender check, signer, journal. (b)
|
||||
Inside the sender check's milter: one socket fewer, but the check would
|
||||
receive every body, and local mail - which has no login - would need the
|
||||
same milter to tell "no login" from "local", deciding a key by the
|
||||
absence of something. **Recommended: (a)** - the socket a message comes
|
||||
through, not a missing login, decides that the server's key may sign
|
||||
it; the sender check stays a header-only milter; and moving the signer
|
||||
into a process of its own later is a change of where it is started.
|
||||
**Decided (owner, 2026-10-15): (a).**
|
||||
- **Q2. The key directory's name.** `/data/opendkim/keys/<domain>/<sel>.private`
|
||||
stays as it is, or becomes `/data/dkim/...` (the entrypoint moves the
|
||||
tree once; KeyTable and SigningTable are deleted). A backup restores
|
||||
only into the image of its own version, so no older archive meets the
|
||||
new name. **Recommended: rename** - the cost is the entrypoint's move,
|
||||
the paths in the code and the backup tests; a directory named after a
|
||||
program the image no longer has would mislead whoever reads `/data`.
|
||||
**Decided (owner, 2026-10-15): rename to `/data/dkim`.**
|
||||
- **Q3. Health.** The Overview's socket check and the process list name
|
||||
OpenDKIM and the syslog relay. **Recommended:** the socket row becomes
|
||||
"DKIM signer" (the panel's `sign.sock`, checked the same way), the two
|
||||
programs leave the process list. A mockup first, as for any page
|
||||
change.
|
||||
**Settled 2026-10-15:** shown to the owner as a picture; it follows from
|
||||
the card's own rule (a row per milter socket Postfix needs, and the
|
||||
signer is strict), so it is not his choice - the two rows as drawn.
|
||||
|
||||
## Stages
|
||||
|
||||
### Stage 1 - the signer, beside OpenDKIM (no release)
|
||||
|
||||
- [ ] `internal/milter/sign.go`: the session (Connect, MailFrom with
|
||||
`{auth_authen}`, Header, Headers, BodyChunk, Body), `OptHeaderLeadingSpace`,
|
||||
`InsertHeader(0, "DKIM-Signature", ...)` (the server's `Actions` gain
|
||||
add-header), the key read per message from the key file (a small cache
|
||||
keyed by path and mtime), the selector from the store as `Record` reads
|
||||
it.
|
||||
- [ ] Unit tests: sign and verify with go-msgauth; folded and tab-led
|
||||
headers; a header absent and present twice; an empty body; a body
|
||||
without a final newline; a login with no domain and a missing key file
|
||||
answer tempfail; the server's socket never signs with a domain's key.
|
||||
- [ ] `cmd/panel`: the role, its two sockets (`listenMilter`, group
|
||||
selfpost, 0660), the wrapper waits for them.
|
||||
- [ ] `build/postfix-config.sh`: `smtpd_milters` = sender, sign, OpenDKIM,
|
||||
journal (OpenDKIM kept for the cross-check); `non_smtpd_milters` =
|
||||
sign-local, OpenDKIM; `disable_mime_output_conversion=yes`.
|
||||
- [ ] e2e: dkimpy in the stand; every positive send and the notification
|
||||
verified by dkimpy against the panel's signature; the panel's and
|
||||
OpenDKIM's `bh=`/`h=` compared.
|
||||
|
||||
### Stage 2 - OpenDKIM removed (no release)
|
||||
|
||||
- [ ] Chain: OpenDKIM out of both milter lists.
|
||||
- [ ] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
|
||||
`Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor`
|
||||
(its only caller is the reload), `internal/syslogrelay`,
|
||||
`internal/milter/mailparse.go` and its tests and testdata, the
|
||||
sender-check texts; Resync's "OpenDKIM's tables" step; Health's
|
||||
liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`.
|
||||
- [ ] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
|
||||
programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost
|
||||
opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL`
|
||||
capability (its only use was the reload).
|
||||
- [ ] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint
|
||||
copies the tree once and leaves the old one for a rollback to 2.7.x until
|
||||
the next release; KeyTable and SigningTable go with the new layout.
|
||||
- [ ] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks,
|
||||
the 256-byte test rewritten for the new rule.
|
||||
|
||||
### Stage 3 - documents, the canary, the cut
|
||||
|
||||
- [ ] architecture § OpenDKIM -> § DKIM signing; security.md (the
|
||||
mail-path bullets, the accepted risks that were OpenDKIM's); guide;
|
||||
development's cited-package table (opendkim and libopendkim11 leave);
|
||||
the assumptions register's header notes what left; help (design commit
|
||||
first) and Health's label (Q3, mockup first).
|
||||
- [ ] Reviews: technical and security.
|
||||
- [ ] On the test server: a day of the owner's mail; one message from an
|
||||
application through 465 and one notification to a real mailbox, the
|
||||
headers read (dkim=pass with the panel's `d=`).
|
||||
- [ ] Cut `2.8.0`.
|
||||
|
||||
## Risks
|
||||
|
||||
- **A signing defect fails DMARC for every tenant at once.** The answer is
|
||||
the oracle (another implementation), the cross-check against OpenDKIM on
|
||||
the same messages before it goes, and the canary before the cut.
|
||||
- **go-msgauth is small and released slowly.** It can be vendored if it
|
||||
ever matters; the signing path is about 300 lines of it.
|
||||
- **Rollback:** until stage 2 ships, the chain change is one line; after
|
||||
the cut, the previous image (2.7.x) still has OpenDKIM and reads the same
|
||||
keys - under Q2's rename, from the old path, which the move leaves as a
|
||||
copy until the next release.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Plan: panel-notifications
|
||||
|
||||
**Status:** shipped in `2.3.0` (2026-10-12); agreed the same day - the design questions were closed with the owner and he took it into work ("Делаем")
|
||||
**Date:** 2026-10-12
|
||||
**Version:** `2.3.0` MINOR (one migration; opt-out by a switch).
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
The panel writes to its administrators when something needs them, instead of
|
||||
waiting to be opened.
|
||||
|
||||
## Decisions (owner, 2026-10-12)
|
||||
|
||||
### Events of the first version
|
||||
|
||||
Four, each of them something the panel can already tell and a person has to
|
||||
act on:
|
||||
|
||||
| Event | Who is told |
|
||||
|---|---|
|
||||
| A sending domain's DNS record stopped matching (SPF, DKIM, DMARC) | global users, and `domain` users the domain is assigned to |
|
||||
| A level-2 rate limit refused mail | the same, for the domain of the limit |
|
||||
| The TLS certificate is about to expire | global users |
|
||||
| A message has been in the queue for more than **three days** | global users |
|
||||
|
||||
Three days, not one (owner: "one day is not that much"): Postfix keeps a
|
||||
message five, so two are left to act before it bounces. The threshold is a
|
||||
constant, not a setting.
|
||||
|
||||
Left for later: a process or milter socket down (a relay in that state often
|
||||
cannot send), an inbound upstream unreachable, DMARC failures appearing
|
||||
(noisy). Mail held in quarantine is not an event: quarantine is an idea
|
||||
outside the roadmap.
|
||||
|
||||
### Sender and path
|
||||
|
||||
From `postmaster@<SELFPOST_HOSTNAME>`, submitted locally. Not from one of the
|
||||
sending domains (owner): with several domains the choice is arbitrary, and a
|
||||
message about the certificate has no business coming from a domain.
|
||||
|
||||
These messages have to arrive (owner: "we need the best deliverability for
|
||||
them"), so the server's own name gets what a sending domain has:
|
||||
|
||||
- a **DKIM key for the hostname**, generated by the panel, with which
|
||||
notifications are signed - no domain has to be chosen for it. An SPF record
|
||||
alone would pass DMARC, but not after the user's mailbox forwards the
|
||||
message on, and large receivers treat unsigned mail more strictly;
|
||||
- an **SPF record** for the hostname, `v=spf1 a -all`.
|
||||
|
||||
The records themselves are under **Server › Settings** (owner: "the DNS
|
||||
records go to Settings, Health has only the status"), in a box of their own
|
||||
drawn with the record component of a domain's DNS page: host, value to
|
||||
publish, copy buttons, state. **Server › Health** has a card of its own,
|
||||
*Notification mail*, with the state of each record and a link to Settings.
|
||||
A DMARC record for the hostname is not asked for: the name inherits its
|
||||
parent domain's policy, and with SPF and DKIM aligned it passes any.
|
||||
Without the records notifications are still sent, and the card warns.
|
||||
|
||||
Notifications are the panel's own mail: they are not rows of the Outbound log
|
||||
and count against no level-2 limit.
|
||||
|
||||
A relay that cannot send cannot say so; none of the four events is "the relay
|
||||
is down", and that is why.
|
||||
|
||||
### Who chooses what
|
||||
|
||||
- One switch for the instance under **Server › Settings**.
|
||||
- One checkbox per user under **Account**, "send me notifications" - on when
|
||||
the profile has an e-mail. No choice per event: four events, none of them
|
||||
noise. Per-event choice comes when there are more and some are.
|
||||
- A user without an e-mail gets nothing.
|
||||
|
||||
### When a message is sent
|
||||
|
||||
A background check once an hour for DNS, certificate and queue.
|
||||
|
||||
| Event | Sent | "Cleared" message |
|
||||
|---|---|---|
|
||||
| DNS mismatch | after two checks in a row found it (it has held for an hour). A resolver that did not answer is not a mismatch | after two good checks in a row |
|
||||
| Certificate | 14 days before expiry, and again 3 days before if it is still the same certificate | none |
|
||||
| Level-2 limit refused mail | at the first refusal of a sending domain's applications, then at most once a day for that domain (one subject per domain, as built: what the refused row says of its domain is the client's own word, so the registry is asked) | none |
|
||||
| Queue older than three days | when such a message appears | one, when none is left |
|
||||
|
||||
Event state is kept in the database, so a restart announces nothing a second
|
||||
time.
|
||||
|
||||
## To settle while building
|
||||
|
||||
- How the panel submits: `sendmail` as the unprivileged panel user, and what
|
||||
that needs of the image.
|
||||
- Whether a notification carries a link to the panel - there is no setting
|
||||
for the panel's own address.
|
||||
- Where the refusal of a level-2 limit is noticed: in the journal milter as
|
||||
it refuses, or from the `rejected` rows of the send log on the hourly check.
|
||||
- Schema: the user's checkbox, the instance switch, the event state table.
|
||||
|
||||
## Screens
|
||||
|
||||
Not drawn. The Notifications box was taken out of the `account` and
|
||||
`settings` mockups on 2026-10-10 (owner: 2.0 ships no box for a feature that
|
||||
is not built). Drawing them again - one checkbox on Account, one switch on
|
||||
Server › Settings, the SPF check on Server › Health - is the first step, shown
|
||||
to the owner before any code ([panel-redesign.md](panel-redesign.md)
|
||||
§ Enforcement).
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Mockups: Account checkbox, Settings switch and records, Health status card, help topic; accepted by the owner 2026-10-12 (`design:` commit)
|
||||
- [x] Migration and store: user flag, instance switch, event state
|
||||
- [x] Sending: local submission from `postmaster@<hostname>`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address)
|
||||
- [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
|
||||
- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
|
||||
- [x] Panel: Account, Settings, Health card, help
|
||||
- [x] guide.md, architecture.md, security.md, schema-migrations.md
|
||||
- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`)
|
||||
- [x] Technical review — **Opus** (2026-10-12; no Critical. Fixed: refusals counted by the registered domain and only under a limit, with an index of their own; account e-mails may be at public mail hosts; "said" means sent; DNS checks must be 45 minutes apart; the notifier's own mail is not a queue subject and a cut or unreadable listing is no listing; the tables must name the selector the panel shows; an unanswered `a`/`mx` lookup is not an SPF mismatch; a pass stops between domains at shutdown; days are rounded as a person counts. Left as found: a recipient whose copy failed while another's went out is not retried (since 2.5.4 it is: audit 2026-10-12, stage 4); a certificate that lives under 14 days is reported at each renewal)
|
||||
- [x] Security review — **Fable** (2026-10-12; no Critical. Fixed: the limit notification was built on the domain a client writes in `MAIL FROM` - a mail cannon and a way to write to another tenant's users; a ceiling of 60 messages an hour; [security.md](../security.md) corrected on who can submit locally and on what the account e-mail now is. Recorded as accepted, with a roadmap candidate: an application can have a message signed as the server by writing its name into `From:` - the header is not held against the login, for sending domains either)
|
||||
@@ -235,9 +235,9 @@ the first step of that feature's plan — not invented during implementation.
|
||||
| Roadmap item | Where it lands | Built from | Drawn |
|
||||
|---|---|---|---|
|
||||
| inbound-antispam-panel (agreed, `2.1.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` |
|
||||
| inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
|
||||
| inbound-quarantine (an idea, off the roadmap since 2026-10-12) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
|
||||
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` |
|
||||
| panel-notifications (candidate) | per-user event choice on **Account**; one switch under **Server › Settings** | checkboxes in a box, `help` | **no** — drawn once, taken out of `account` and `settings` on 2026-10-10 (owner): 2.0 ships no box for a feature that is not built; redrawn as the first step of its plan |
|
||||
| panel-notifications (candidate) | one checkbox per user on **Account** (no choice per event, owner 2026-10-12); one switch under **Server › Settings** | a checkbox in a box, `help` | **no** — drawn once, taken out of `account` and `settings` on 2026-10-10 (owner): 2.0 ships no box for a feature that is not built; redrawn as the first step of its plan |
|
||||
| password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** |
|
||||
| delivery-log-storage (candidate) | the *Delivery log* box of **Outbound › Log › message**, filled from a table instead of a grep of today's `mail.log` | `log_pane`, unchanged | `out-message` |
|
||||
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
|
||||
@@ -539,5 +539,5 @@ only when both are done. Every stage-2 step ends with the evidence of
|
||||
order suits development, preflight, delivery-log-storage, panel-notifications,
|
||||
password-reset (two screens to draw first), queue as a table with the inbound
|
||||
queue, csrf-tokens
|
||||
(one hidden field in the form partial once security.md is settled),
|
||||
inbound-quarantine after its open questions.
|
||||
(one hidden field in the form partial once security.md is settled).
|
||||
inbound-quarantine is an idea outside the roadmap.
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Plan: preflight (installation check page)
|
||||
|
||||
**Status:** candidate
|
||||
**Date:** 2026-08-19
|
||||
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
A dedicated `/server/preflight` page (global-admin only) that runs infrastructure-level
|
||||
checks and helps the administrator confirm the instance is fully configured and
|
||||
ready to deliver mail. Each failed check shows an actionable recommendation
|
||||
(what to do or where to look).
|
||||
|
||||
## Mockup
|
||||
|
||||
[preflight](../assets/panel-redesign/panel/preflight.html), under the design
|
||||
contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**,
|
||||
path `/server/preflight`.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- rDNS / PTR verification (reuse `dnscheck.Checker.checkServer`)
|
||||
- TLS certificate validity + hostname match (CN/SAN contains `SELFPOST_HOSTNAME`)
|
||||
- Port connectivity: 25, 465, and 587 (if `SUBMISSION_ENABLE`) — dial own
|
||||
public IP from inside the container, expect a 220 banner
|
||||
- HELO/EHLO banner — connect to localhost:25, verify Postfix announces
|
||||
`SELFPOST_HOSTNAME` (not `localhost` or a container ID)
|
||||
- Trusted proxy headers:
|
||||
- If `TRUSTED_PROXY_CIDR` set: verify peer IP is in range and
|
||||
`X-Forwarded-For` is present
|
||||
- If `PANEL_COOKIE_SECURE=true`: verify `X-Forwarded-Proto: https`
|
||||
- If `TRUSTED_PROXY_CIDR` empty: informational warning (panel exposed
|
||||
directly)
|
||||
- DKIM signing — OpenDKIM milter socket reachable (reuse `health.CheckSocket`)
|
||||
- Send test email — admin enters recipient address, system sends via
|
||||
localhost:25, shows queued/error result
|
||||
|
||||
**Out:**
|
||||
|
||||
- Per-domain DNS checks (SPF, DKIM TXT, DMARC) — those live on domain detail
|
||||
pages.
|
||||
- Delivery tracking of test email (only confirms local MTA accepted).
|
||||
|
||||
## Done when
|
||||
|
||||
An administrator can open `/server/preflight`, see a traffic-light summary of all
|
||||
infrastructure prerequisites, read specific fix instructions for any failures,
|
||||
and send a test email to verify end-to-end flow.
|
||||
|
||||
## Risks
|
||||
|
||||
- Port-connectivity check from inside the container may not reflect external
|
||||
reachability if NAT hairpin is unsupported (document limitation).
|
||||
- Test email requires at least one configured domain (or falls back to
|
||||
`postmaster@SELFPOST_HOSTNAME`).
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
Target version cut: TBD (MINOR). One commit per step; code only after roadmap
|
||||
status is **agreed**. See [development.md](../development.md) § Plan checklists.
|
||||
|
||||
- [ ] Port-connectivity helper (`internal/health/port.go`) — dial own IP, read
|
||||
SMTP banner — **Sonnet**
|
||||
- [ ] HELO banner check (connect localhost:25, parse 220 line) — **Sonnet**
|
||||
- [ ] Hostname-in-certificate check (extend or wrap `health.CheckCertificate`)
|
||||
— **Sonnet**
|
||||
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
|
||||
recommendations) — **Sonnet**
|
||||
- [ ] Test-email handler (`POST /server/preflight/test-email`, `net/smtp` to
|
||||
localhost:25) — **Sonnet**
|
||||
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
|
||||
**Sonnet**
|
||||
- [ ] Routes in `web.go`, nav link — **Sonnet**
|
||||
+560
-167
@@ -28,38 +28,60 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
|
||||
| ID | Topic | Status | Progress | Plan |
|
||||
|---|---|---|---|---|
|
||||
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
||||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
||||
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
||||
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
|
||||
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | 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 | — | — |
|
||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — |
|
||||
| test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — |
|
||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — |
|
||||
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | agreed (2026-10-13) | — | — |
|
||||
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
|
||||
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||||
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
|
||||
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
|
||||
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | agreed (2026-10-15) | — | — |
|
||||
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | agreed (2026-10-15) | — | — |
|
||||
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | agreed (2026-10-15) | — | — |
|
||||
| own-mta-builds | Analysis: build Postfix and OpenDKIM ourselves, patched, instead of Debian's packages | analysed 2026-10-15: no own builds; three rows proposed instead | — | [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md) |
|
||||
| port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — |
|
||||
| base-trixie | The image's base from Debian bookworm (LTS since 2026-07-12) to trixie: Postfix 3.10 with the 2026 security batches | shipped `2.7.0` | — | — |
|
||||
| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; plan agreed, next) | 0/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) |
|
||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — |
|
||||
| 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 | done 2026-10-15 (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`, 4 `2.5.4`, 5 `2.6.0`; stage 6 docs and tests, no cut) | 81/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); the next stage is
|
||||
**inbound-antispam-panel** (`2.1.0`), built in the new component kit —
|
||||
styling its two screens once instead of twice. panel-docs shipped in
|
||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
|
||||
kit — styling its two screens once instead of twice. **delivery-log-storage**
|
||||
shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and
|
||||
**from-header-binding** as `2.4.0`. Stage 0 of
|
||||
[audit-2026-10-12](audit-2026-10-12.md) shipped as `2.4.2` (2026-10-14), its
|
||||
stages 1 and 2 as `2.5.0` the same day, stage 2b as `2.5.1`, stage 2c as
|
||||
`2.5.2` (2026-10-15), and stages 3, 4 and 5 as `2.5.3`, `2.5.4` and
|
||||
`2.6.0` the same day; stage 6 (documents and tests, no cut) closed the
|
||||
audit. Of the three rows from [own-mta-builds](#own-mta-builds),
|
||||
**port25-bare-newline** shipped as `2.6.1` the same day and
|
||||
**base-trixie** as `2.7.0`; panel-dkim-signing follows. What is agreed after it is marked so in the index; every other
|
||||
remaining item is a candidate. panel-docs shipped in
|
||||
[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
|
||||
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
|
||||
[security.md](security.md)); domain-stats-auto-ratelimit in
|
||||
`[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
|
||||
queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
|
||||
`[1.3.0]`. Candidates need explicit agreement before they join the queue.
|
||||
|
||||
The 2026-08-19 full-tree code review is closed except for four items, all
|
||||
listed above and none of them urgent: [csrf-tokens](#csrf-tokens),
|
||||
[template-data-typing](#template-data-typing),
|
||||
[structured-logging](#structured-logging) and
|
||||
[review-2026-08-followups](#review-2026-08-followups). What that review
|
||||
produced is in [CHANGELOG.md](../CHANGELOG.md) `[1.9.2]`; its own document
|
||||
was removed once these four items carried everything still open, and stays in
|
||||
git history.
|
||||
The 2026-08-19 full-tree code review is closed except for one item, listed
|
||||
above and not urgent: [origin-strict](#origin-strict) (its "add CSRF tokens",
|
||||
decided otherwise on 2026-10-13). Of its other three,
|
||||
template-data-typing is done (the last untyped map, the health sampler's, went
|
||||
in [audit-2026-10-12](audit-2026-10-12.md) stage 4, `2.5.4`),
|
||||
structured-logging is [dropped](#dropped-with-the-condition-for-return)
|
||||
(2026-10-13) and [review-2026-08-followups](#review-2026-08-followups) is
|
||||
closed (2026-10-14). What that review produced is in
|
||||
[CHANGELOG.md](../CHANGELOG.md) `[1.9.2]`; its own document was removed once
|
||||
these four items carried everything still open, and stays in git history.
|
||||
|
||||
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||
the **Implementation checklist** in its linked plan. The `Progress` column above
|
||||
@@ -68,24 +90,6 @@ is `done/total` checklist steps in that plan ([development.md](development.md)
|
||||
|
||||
---
|
||||
|
||||
## contributing
|
||||
|
||||
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||
before a PR, the commit protocol; [development.md](development.md) links to it
|
||||
rather than repeating it.
|
||||
|
||||
**Boundary:** process documentation; worth writing once there is an external
|
||||
flow of PRs.
|
||||
|
||||
**Done when:** the file is in the root and development.md does not duplicate
|
||||
it.
|
||||
|
||||
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||||
priority.
|
||||
**Version:** no bearing on semver.
|
||||
|
||||
---
|
||||
|
||||
## panel-docs
|
||||
|
||||
**Status:** done — shipped in `[1.8.0]` (2026-08-18). The implementation plan
|
||||
@@ -121,8 +125,8 @@ action) plus editable allow/deny lists that tune the external filter — without
|
||||
shipping rspamd or another engine inside the image.
|
||||
|
||||
**Boundary:** metadata journal and list sync only; SelfPost does not run the
|
||||
filter ([product.md](product.md)). Quarantine:
|
||||
[inbound-quarantine](#inbound-quarantine). Design closed in
|
||||
filter ([product.md](product.md)). Quarantine is not on the roadmap
|
||||
([Ideas](#ideas-not-on-the-roadmap)). Design closed in
|
||||
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Decisions.
|
||||
|
||||
**Done when:** with inbound relay enabled, the operator sees journal rows
|
||||
@@ -133,130 +137,126 @@ hook is set.
|
||||
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
|
||||
journal PII; global-admin-only RBAC.
|
||||
|
||||
**Version:** `2.1.0` MINOR (was `1.10.0`; reordered after the redesign on 2026-10-09); **agreed**.
|
||||
**Version:** shipped `2.1.0` (2026-10-12), MINOR (was `1.10.0`; reordered after the redesign on 2026-10-09).
|
||||
|
||||
---
|
||||
|
||||
## inbound-quarantine
|
||||
## health-setup-checks
|
||||
|
||||
**Goal:** optional hold for suspicious inbound mail — review in the panel, then
|
||||
release to upstream, discard, or tune lists. Design not settled.
|
||||
**Decided by the owner on 2026-10-13**, in place of the former candidate
|
||||
*preflight* (a page of its own, Server › Preflight, planned in August before
|
||||
Server › Health existed). Of that plan's eight checks Health already makes
|
||||
three (reverse DNS, certificate validity, the DKIM socket); five were missing.
|
||||
Three of the five go into Health, one becomes [test-email](#test-email), one
|
||||
is not built ([Dropped](#dropped-with-the-condition-for-return)). No second
|
||||
page of checks.
|
||||
|
||||
**Boundary:** expands inbound from pure relay toward short-lived message
|
||||
storage; may conflict with "no mailboxes" in [product.md](product.md) until
|
||||
product explicitly agrees. Alternatives (rspamd-only quarantine vs SelfPost
|
||||
store) are listed in [plans/inbound-quarantine.md](plans/inbound-quarantine.md).
|
||||
**Goal:** three more lines on Server › Health, in the cards that are there:
|
||||
|
||||
**Done when:** TBD after open questions in the plan are answered.
|
||||
- **The certificate is for this server**: its names include
|
||||
`SELFPOST_HOSTNAME` (TLS certificate card). Today a certificate for another
|
||||
name is "valid".
|
||||
- **Postfix greets with the hostname**: the 220 line on port 25 names
|
||||
`SELFPOST_HOSTNAME`, not `localhost` or a container id (Hostname card).
|
||||
- **The trusted proxy**: the address the panel sees this request from, and
|
||||
whether it took it from `X-Forwarded-For`. With `TRUSTED_PROXY_CIDR` wrong
|
||||
or empty behind a proxy the header is ignored
|
||||
([internal/web/auth/auth.go](../internal/web/auth/auth.go)), every client
|
||||
has the proxy's address and the sign-in limiter is shared by all of them -
|
||||
silently.
|
||||
|
||||
**Dependencies / risks:** inbound relay `[1.4.0]`; storage/retention/backup
|
||||
size; malware in held MIME; overlap with
|
||||
[inbound-antispam-panel](#inbound-antispam-panel). No implementation checklist
|
||||
yet.
|
||||
**Boundary:** lines in existing cards, each with what to do when it fails;
|
||||
no new card unless the mockup shows the lines do not fit. Mockup-first
|
||||
([plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement). The
|
||||
mockup `preflight` stays drawn and unbuilt.
|
||||
|
||||
**Version:** TBD; `candidate` until explicitly agreed.
|
||||
**Done when:** each line is shown wrong on the e2e stand or in a test with a
|
||||
wrong certificate name, a wrong greeting and an untrusted proxy, and right on
|
||||
the test server.
|
||||
|
||||
**Version:** `2.x` MINOR; `agreed`.
|
||||
|
||||
---
|
||||
|
||||
## preflight
|
||||
## test-email
|
||||
|
||||
**Goal:** a `/preflight` page (global-admin only) that verifies instance-level
|
||||
infrastructure: rDNS/PTR, TLS certificate + hostname match, port 25/465/587
|
||||
reachability, HELO banner correctness, reverse-proxy headers, DKIM milter
|
||||
socket, and a test-email sender. Each check shows an actionable fix
|
||||
recommendation on failure.
|
||||
**Decided by the owner on 2026-10-13**, taken out of the former *preflight*.
|
||||
|
||||
**Boundary:** instance infrastructure only; per-domain DNS checks remain on
|
||||
domain detail pages. Does not track test-email delivery beyond local MTA
|
||||
acceptance.
|
||||
**Goal:** a global administrator sends a test message from the panel to an
|
||||
address they type, and sees whether Postfix took it; the message then appears
|
||||
in Outbound › Log like any other. Today nobody can send through the server
|
||||
without setting up an application first.
|
||||
|
||||
**Done when:** the page loads, all checks run with traffic-light results and
|
||||
recommendations, and the test-email form successfully submits a message through
|
||||
Postfix.
|
||||
**Boundary:** global role only; from `postmaster@SELFPOST_HOSTNAME`, the
|
||||
sender notifications use, or from a sending domain the administrator picks -
|
||||
settled at the mockup. It does not follow the message further than the log
|
||||
does. Where the form lives (Health, or a sending domain's page) is settled at
|
||||
the mockup; mockup-first.
|
||||
|
||||
**Dependencies / risks:** port-connectivity check may not reflect external
|
||||
reachability behind NAT without hairpin; test email needs at least one domain or
|
||||
falls back to `postmaster@SELFPOST_HOSTNAME`.
|
||||
**Done when:** the form sends, the row appears in the log, the form is
|
||||
rate-limited and refuses a recipient list, and it passes a security review -
|
||||
a form that sends mail is a new surface.
|
||||
|
||||
**Version:** `1.x` MINOR; `candidate`.
|
||||
**Version:** `2.x` MINOR; `agreed`.
|
||||
|
||||
---
|
||||
|
||||
## csrf-tokens
|
||||
## origin-strict
|
||||
|
||||
**Goal:** a per-session CSRF token on every state-changing form, checked
|
||||
server-side in addition to the `Sec-Fetch-Site` / `Origin` check the panel
|
||||
relies on today (`originAllowed` in
|
||||
[internal/web/security.go](../internal/web/security.go)).
|
||||
**Decided by the owner on 2026-10-13**, in place of the former candidate
|
||||
*csrf-tokens* (per-session tokens on every form, recorded by the 2026-08-19
|
||||
review): no tokens; the origin check is made strict instead.
|
||||
|
||||
**Boundary:** defence in depth, not a replacement — the header check stays and
|
||||
keeps covering requests that carry no form. The token is bound to the existing
|
||||
session record; no new session model, no cookie changes.
|
||||
**Goal:** `originAllowed`
|
||||
([internal/web/security.go](../internal/web/security.go)) refuses a
|
||||
state-changing request that carries neither `Sec-Fetch-Site` nor `Origin`.
|
||||
Today such a request is let through, which leaves a client that sends neither
|
||||
header open to a request forged by any site
|
||||
([security.md](security.md) § Accepted risks).
|
||||
|
||||
**Done when:** every POST form and htmx fragment that changes state carries a
|
||||
token, a missing or mismatched token is refused with a test that proves it, and
|
||||
[security.md](security.md) describes the layered model in one place instead of
|
||||
two.
|
||||
**Boundary:** one branch of one function, its test, and the documents. No
|
||||
template changes, no hidden fields. Before the change, find what posts to the
|
||||
panel without a browser (the e2e stand, scripts) and make it send `Origin`.
|
||||
|
||||
**Dependencies / risks:** [security.md](security.md) currently records the
|
||||
opposite decision — origin checking without tokens, with the accepted risk and
|
||||
its revisit triggers written out in the ADR § *CSRF via origin checking*. The
|
||||
2026-08-19 review recorded "add tokens" without turning it into a task, which is
|
||||
why this item exists. Settle which decision stands before writing code; both
|
||||
outcomes are honest, and "keep headers" means deleting this item rather than
|
||||
leaving the contradiction standing. The work itself is a hidden field in roughly
|
||||
two dozen forms plus the htmx swap paths.
|
||||
**Done when:** a `POST` with neither header is refused with a test that proves
|
||||
it, the e2e stand passes, the accepted risk about header-less requests is
|
||||
struck from [security.md](security.md) and its ADR states the strict rule.
|
||||
|
||||
**Version:** `1.x` MINOR; `candidate`.
|
||||
**Dependencies / risks:** a browser that sends neither header can no longer
|
||||
save anything in the panel - accepted, the panel's users pick their own
|
||||
browsers. Tokens stay a recorded alternative, not a task: the conditions under
|
||||
which they are built are in [security.md](security.md), ADR § *CSRF via origin
|
||||
checking* (**Revisit if**).
|
||||
|
||||
**Version:** `2.x` PATCH; `agreed`.
|
||||
|
||||
---
|
||||
|
||||
## template-data-typing
|
||||
## backup-reauth
|
||||
|
||||
**Goal:** replace the `map[string]any` template payloads in
|
||||
`internal/web/handlers` with typed structs (`DomainDetailData`, `SettingsData`,
|
||||
...), so a renamed or mistyped key is a compile error instead of a silently
|
||||
empty page section.
|
||||
**Agreed with the owner on 2026-10-13**, out of
|
||||
[review-2026-08-followups](#review-2026-08-followups).
|
||||
|
||||
**Boundary:** handler-to-template plumbing only; no template redesign, no route
|
||||
or behaviour change. `renderDomainDetail`
|
||||
([handlers_apps.go](../internal/web/handlers/handlers_apps.go)) sets around
|
||||
forty keys on one map and is the case that motivates the item; roughly ten
|
||||
render sites still pass maps.
|
||||
**Problem:** Server › Backup hands the full backup and a domain export to any
|
||||
live session of a global administrator
|
||||
([handlers_backup.go](../internal/web/handlers/handlers_backup.go)). The
|
||||
archive holds the whole database - panel password hashes, application (SASL)
|
||||
passwords, DKIM keys - so a stolen session cookie, or a panel left open, is a
|
||||
quiet copy of the server. *Encrypt with a password* does not help: the thief
|
||||
chooses that password.
|
||||
|
||||
**Done when:** the page handlers pass structs, the shared footer/base fields
|
||||
(`Version`, `Copyright`, `SourceURL`) still arrive on every page, and the
|
||||
template guard tests pass unchanged.
|
||||
**Goal:** both downloads ask for the current account password, checked the
|
||||
way a password change checks it and under the same limiter.
|
||||
|
||||
**Dependencies / risks:** mechanical but wide — it touches every
|
||||
`handlers_*.go` and every template that reads those keys, and a missed key is
|
||||
invisible unless a test renders that page. Worth pairing with the handler tests
|
||||
in [review-2026-08-followups](#review-2026-08-followups) so the pages are
|
||||
covered before they are rewired. Reviewer's estimate: 2–3 hours.
|
||||
**Boundary:** one field in each of the two forms (mockup-first); import is
|
||||
not touched; the `selfpost-backup` CLI is not touched - it runs inside the
|
||||
container.
|
||||
|
||||
**Version:** no bearing on semver; `candidate`.
|
||||
**Done when:** a download without the password or with a wrong one is refused
|
||||
with a test that proves it, attempts are limited, and
|
||||
[security.md](security.md) § Backup says so.
|
||||
|
||||
---
|
||||
|
||||
## structured-logging
|
||||
|
||||
**Goal:** move from `log.Printf` to `log/slog` — levels, structured fields, and
|
||||
one logger injected through constructors, instead of the package-level
|
||||
`logf = log.Printf` alias repeated in five packages.
|
||||
|
||||
**Boundary:** internal logging only. The destination stays stdout/stderr under
|
||||
supervisord; no log shipping, no new dependency. Lines the panel's own Status
|
||||
page and log tailer depend on must keep parsing.
|
||||
|
||||
**Done when:** packages take a `*slog.Logger`, the per-package `logf` aliases
|
||||
are gone, and levels are used deliberately (an error is not an info).
|
||||
|
||||
**Dependencies / risks:** the log tailer reads Postfix's log rather than the
|
||||
panel's, so the blast radius is small — but anything that greps the panel's
|
||||
output (e2e assertions, operator habits) sees a new format. Reviewer's
|
||||
estimate: 3–4 hours.
|
||||
|
||||
**Version:** no bearing on semver; `candidate`.
|
||||
**Version:** `2.x` MINOR; `agreed`.
|
||||
|
||||
---
|
||||
|
||||
@@ -269,14 +269,14 @@ is in the table below; the rest of it shipped in
|
||||
|
||||
| Area | Item |
|
||||
|---|---|
|
||||
| Security | Re-authenticate (ask for the current password) before a backup download — [handlers_backup.go](../internal/web/handlers/handlers_backup.go) hands the archive to any signed-in session |
|
||||
| Tests | The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests |
|
||||
| Tests | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly |
|
||||
| Tests | Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats |
|
||||
| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job |
|
||||
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
|
||||
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
|
||||
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
|
||||
| Security | ~~Re-authenticate (ask for the current password) before a backup download~~ — struck: agreed on 2026-10-13 as an item of its own, [backup-reauth](#backup-reauth) |
|
||||
| Tests | ~~The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests~~ — struck: a step of audit-2026-10-12 stage 4 (DMARC reports tested where they come in) |
|
||||
| Tests | ~~No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly~~ — struck: users with stage 2 (`submitUserUpdate`), DMARC with stage 4; apps and domains get theirs when a step changes them - a test without a change finds nothing the e2e does not |
|
||||
| Tests | ~~Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats~~ — struck: the corrupted archive goes to stage 3, DMARC XML to stage 4, paging edges to stage 4 (`parsePage`), concurrency of the last global to stage 2; error templates, other concurrency and zero-traffic stats are dropped (owner, 2026-10-14) - code no step changes, and a test kept for its own sake finds nothing |
|
||||
| Edge case | ~~`internal/secretfile` accepts an empty password at library level~~ — struck: audit-2026-10-12 stage 3 (M5) |
|
||||
| Edge case | ~~The log fingerprint in `logtail/offset.go` can collide across a rotation — include the inode~~ — struck: agreed on 2026-10-13 as part of a step of [audit-2026-10-12](audit-2026-10-12.md) stage 2b (a log rotated while the panel was down is read to its end) |
|
||||
| Edge case | ~~`parsePage` does not clamp to `lastPage`~~ — struck: audit-2026-10-12 stage 4 |
|
||||
| Edge case | ~~`dnscheck/resolver.go` fans out four parallel queries where a fallback chain would do~~ — struck: finding M14 of [audit-2026-10-12](audit-2026-10-12.md) |
|
||||
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
|
||||
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
|
||||
| UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` |
|
||||
@@ -286,10 +286,10 @@ apart from the backup re-auth prompt. Anything here that grows past an hour
|
||||
becomes its own roadmap item instead.
|
||||
|
||||
**Done when:** each row is either implemented or struck with a written reason.
|
||||
**Closed 2026-10-14** with the owner: every row is struck above, placed in a
|
||||
stage of [audit-2026-10-12](audit-2026-10-12.md) or dropped with its reason.
|
||||
|
||||
**Dependencies / risks:** the test rows overlap
|
||||
[template-data-typing](#template-data-typing) — write the handler tests first if
|
||||
both are taken up. One finding of that review was **declined** and is
|
||||
**Dependencies / risks:** One finding of that review was **declined** and is
|
||||
deliberately absent: `//go:build linux` for the `/proc` reader in
|
||||
[internal/health/machine.go](../internal/health/machine.go). That code is
|
||||
parameterised by root directory, calls nothing platform-specific, already
|
||||
@@ -348,38 +348,43 @@ time to final status plus the queue lifetime), not to the id alone; the table
|
||||
grows with the send log (small — a few hundred bytes per message) and is
|
||||
covered by the same retention.
|
||||
|
||||
**Version:** `2.x` MINOR; `candidate`. Fits after the redesign, built against
|
||||
the kit's `log_pane`.
|
||||
**Version:** shipped `2.2.0` (2026-10-12), MINOR; option 1. Decisions and
|
||||
checklist: [plans/delivery-log-storage.md](plans/delivery-log-storage.md).
|
||||
|
||||
---
|
||||
|
||||
## panel-notifications
|
||||
|
||||
**Goal:** the panel tells its administrators when something needs them instead
|
||||
of waiting to be opened: a DNS record stops matching, a domain or application
|
||||
hits its rate limit and mail is refused, an inbound upstream is unreachable,
|
||||
DMARC failures appear, mail is held in quarantine; for the global
|
||||
administrator also the TLS certificate nearing expiry, a process or milter
|
||||
socket down, a queue older than a day.
|
||||
of waiting to be opened. First version, four events: a sending domain's DNS
|
||||
record stops matching, a level-2 rate limit refuses mail, the TLS certificate
|
||||
is about to expire, a message has been in the queue for more than three days.
|
||||
|
||||
**Boundary:** e-mail only, sent through the relay itself from
|
||||
`postmaster@<hostname>`. Each user picks events under **Account**; a domain
|
||||
administrator hears only about assigned domains. One message when a problem
|
||||
starts and one when it clears — no streams, no digests in v1. One instance
|
||||
switch under **Server › Settings**. No webhooks, no chat integrations.
|
||||
**Boundary:** e-mail only, from `postmaster@<hostname>`, submitted locally -
|
||||
not from one of the sending domains. One checkbox per user under **Account**
|
||||
(no choice per event), one instance switch under **Server › Settings**; a
|
||||
domain administrator hears only about assigned domains. No streams, no
|
||||
digests, no webhooks, no chat integrations. Not in the first version: a
|
||||
process or milter down, an inbound upstream unreachable, DMARC failures.
|
||||
|
||||
**Done when:** an event from the list reaches the account e-mail of every user
|
||||
who chose it and may see its subject, once on start and once on clear; a user
|
||||
without an e-mail gets nothing; the switch turns it all off.
|
||||
**Done when:** each of the four events reaches the account e-mail of every
|
||||
user who has notifications on and may see its subject, by the rules of the
|
||||
plan (a "cleared" message for DNS and the queue only); a user without an
|
||||
e-mail gets nothing; the switch turns it all off.
|
||||
|
||||
**Dependencies / risks:** the account e-mail (2.0 baseline, see
|
||||
[plans/panel-redesign.md](plans/panel-redesign.md) § Account e-mail); a relay
|
||||
that cannot send cannot report that it cannot send — server events need the
|
||||
direct-to-MX path or stay panel-only; event state must survive restarts so a
|
||||
reboot does not re-announce everything. Screens are drawn: `account`,
|
||||
`settings`.
|
||||
that cannot send cannot report that it cannot send - none of the four events
|
||||
is "the relay is down"; mail from the bare hostname needs an SPF record for
|
||||
it to be believed, which Server › Health will check; event state must survive
|
||||
restarts so a reboot does not re-announce everything. The screens are not
|
||||
drawn: the Notifications box was taken out of `account` and `settings` on
|
||||
2026-10-10 and is redrawn as the first step.
|
||||
|
||||
**Version:** `2.x` MINOR; `candidate`.
|
||||
**Version:** shipped `2.3.0` (2026-10-12), MINOR - the design questions were closed
|
||||
with the owner on 2026-10-12
|
||||
([plans/panel-notifications.md](plans/panel-notifications.md)) and he took it into
|
||||
work the same day.
|
||||
|
||||
---
|
||||
|
||||
@@ -392,8 +397,22 @@ account e-mail instead of asking the global administrator to set a new one.
|
||||
same answer whether or not the username exists. The only global administrator
|
||||
without a working e-mail still recovers from the container (today's path).
|
||||
|
||||
**The e-mail must be confirmed (owner, 2026-10-13).** A reset link goes only
|
||||
to an address its owner has confirmed; an account whose e-mail is not
|
||||
confirmed has no reset, and recovers as today (the global administrator sets
|
||||
a password, or the container). The panel does not confirm addresses today -
|
||||
`users.email` is whatever was typed - so the item builds that first: saving
|
||||
an e-mail under Account mails a one-time link to it, opening the link marks
|
||||
the address confirmed (a migration: the mark on `users`), changing the
|
||||
address clears the mark, and Account shows which it is. An address typed for
|
||||
a user by the global administrator is unconfirmed until that user opens the
|
||||
link. To settle at the mockup: whether notifications (2.3.0) keep going to an
|
||||
unconfirmed address.
|
||||
|
||||
**Done when:** the flow works end to end, tokens expire and cannot be reused,
|
||||
requests are rate-limited, and it passes a security review.
|
||||
requests are rate-limited, an account with an unconfirmed e-mail gets no
|
||||
link and the sign-in page answers the same for it, and it passes a security
|
||||
review.
|
||||
|
||||
**Dependencies / risks:** the account e-mail; an account-takeover surface where
|
||||
there was none — security review is part of the item, not an afterthought.
|
||||
@@ -401,7 +420,145 @@ Not drawn yet. Changing the account e-mail must then require the current
|
||||
password (2.0 security review), because the e-mail becomes where a reset link
|
||||
goes and today Account saves it without the password.
|
||||
|
||||
**Version:** `2.x` MINOR; `candidate`.
|
||||
**Version:** `2.x` MINOR; `agreed` (owner, 2026-10-13: domain administrators
|
||||
to come need it - they are not the owner and should not have to ask him for
|
||||
a new password). Mockup-first; changing the account e-mail asks for the
|
||||
current password in the same cut.
|
||||
|
||||
---
|
||||
|
||||
## local-time
|
||||
|
||||
**Agreed with the owner on 2026-10-13.** It came from one cell: *Today* on
|
||||
Health's Sender check card is the UTC day and does not say so. Offered a
|
||||
label ("Today, UTC") or the local day, he chose the local day - which is a
|
||||
decision about the whole panel, not one cell.
|
||||
|
||||
**Today:** every time the panel shows is UTC and says so (*Time, UTC* in the
|
||||
logs, "UTC" after a date), and everything counted by day - the sender
|
||||
check's days, the delivery-log measurement, the statistics behind the
|
||||
charts, retention - is counted by the UTC day.
|
||||
|
||||
**Goal:** the panel shows time in a chosen time zone, and "today" is that
|
||||
zone's day.
|
||||
|
||||
**Where the zone comes from (owner, 2026-10-13):** one setting of the
|
||||
instance, *Time zone* under Server › Settings, UTC by default; it holds for
|
||||
every user and for notification mail. Not the browser's zone: only a script
|
||||
could read it, and the panel works without JavaScript. Not a setting of each
|
||||
user yet - **that is added (the same list under Account, "as the server" by
|
||||
default) when** a user of the panel lives in another zone than the one set
|
||||
for the instance, which the first domain administrator who is not the owner
|
||||
may bring.
|
||||
|
||||
**What is written beside a time (owner, 2026-10-13):** nothing. The "UTC"
|
||||
labels go - the column is *Time*, a date is a date - and the zone is stated
|
||||
in one place, the setting. His reason: there is nobody to send a line of the
|
||||
panel to yet. **The offset is written where "UTC" is today ("Time, UTC+3",
|
||||
"2026-09-21 16:40 UTC+3") when** times of the panel start to be read by
|
||||
someone who does not know the instance's zone: a second user of the panel,
|
||||
or lines copied out of it to another person.
|
||||
|
||||
**A trap this leaves, known in advance (owner asked for it to be written
|
||||
down, 2026-10-13):** Server › System log, and the *Delivery log* box on a
|
||||
message's page, show the lines of `mail.log` as Postfix wrote them, each with
|
||||
its own stamp ("Oct 13 14:01:52"). Those stamps are the container's time -
|
||||
UTC unless the container is given a `TZ` - and they are not rewritten. Once
|
||||
the rest of the panel is in the instance's zone and no time carries a label,
|
||||
a message that the Outbound log puts at 17:01 has lines stamped 14:01 on its
|
||||
own page, with nothing on the screen to say why. The plan must close this
|
||||
before the item ships, one way or another: rewrite the stamp when a line is
|
||||
shown, say on those two screens which clock the lines use, or run the
|
||||
container in the instance's zone (which moves `mail.log` itself and what the
|
||||
tailer reads). The guide's section on time must say the same thing as the
|
||||
screens.
|
||||
|
||||
**How a day is counted (owner, 2026-10-13):** the tables that keep a row
|
||||
per day - `sender_check_days`, and `delivery_log_days` of
|
||||
[audit-2026-10-12](audit-2026-10-12.md) stage 1 - key the row by the date in
|
||||
the instance's zone at the moment of counting, from the release on. Rows
|
||||
counted before it stay UTC days. Changing the zone later leaves the rows
|
||||
already written as days of the old zone, a shift of a few hours in a
|
||||
statistic; accepted - the zone is set once, and rows per hour (which would
|
||||
survive a change) cost a migration for it. The charts and statistics group
|
||||
the log by hour already and need nothing.
|
||||
|
||||
**Open, to settle at the mockups:** the trap above; which of the screens
|
||||
that show a date show the year.
|
||||
|
||||
**Boundary:** what the panel shows and how it groups by day. `mail.log`,
|
||||
the database's stored instants and the e2e stay as they are.
|
||||
|
||||
**Version:** `2.x` MINOR; `agreed`; the plan is written when it is taken up.
|
||||
|
||||
---
|
||||
|
||||
## phone-desktop-mode
|
||||
|
||||
**Problem (owner, 2026-10-11):** the panel opened on a phone with the
|
||||
browser's "desktop site" switched on does not become the desktop panel.
|
||||
Seen on Server › Health at `selfpost.mixfed.ru`: the page body is laid out in
|
||||
columns (Machine and Processes side by side, three cards in the next row),
|
||||
while the navigation is the phone one - not a bar but a stack with every
|
||||
section open (Outbound, Inbound, Server, the user menu), filling the first
|
||||
screen before the page starts. The content also sits flush against the left
|
||||
edge of the screen.
|
||||
|
||||
**Goal:** find out why, then decide what the panel should be in that mode -
|
||||
the desktop panel, or a phone panel that is at least consistent.
|
||||
|
||||
**Where to look first (a guess, not checked):** "desktop site" on Android
|
||||
lays a page out about 980 px wide. Bulma turns the navigation into a bar at
|
||||
1024 px and puts columns side by side from 769 px, so 980 px falls between
|
||||
the two: desktop columns under a touch navigation. `panel.css` has its own
|
||||
breakpoints at 768, 1023 and 1407 px. Check the width the phone really
|
||||
reports, why every dropdown is open rather than folded behind the burger,
|
||||
and where the left padding went.
|
||||
|
||||
**Boundary:** the navigation and page gutter between 769 and 1023 px, which
|
||||
is also a tablet in portrait and a narrow desktop window - the same defect is
|
||||
expected there. A design change is mockup-first
|
||||
([plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement).
|
||||
|
||||
**Done when:** the cause is written down here, and the panel at that width
|
||||
has one coherent layout, checked on a phone in "desktop site" mode and in a
|
||||
980 px window.
|
||||
|
||||
**Version:** `2.x` PATCH or MINOR; `agreed` (owner, 2026-10-13), taken up
|
||||
after stages 1 and 2 of [audit-2026-10-12](audit-2026-10-12.md) - mail shown
|
||||
wrong and the tenant boundary come before layout.
|
||||
|
||||
---
|
||||
|
||||
## from-header-binding
|
||||
|
||||
**Problem (security review of panel-notifications, 2026-10-12):** Postfix
|
||||
holds the envelope sender against the SASL login
|
||||
(`reject_sender_login_mismatch`), and OpenDKIM chooses the signing key by the
|
||||
`From:` header. Nothing holds the header against the login. An application
|
||||
of one sending domain can therefore write another registered domain - or,
|
||||
since 2.3.0, the server's own name - into `From:` and have the message signed
|
||||
with that name's key: mail that passes DMARC as a domain the application has
|
||||
no right to, or that looks like a notification of the panel.
|
||||
|
||||
**Agreed with the owner on 2026-10-12**, built and reviewed the same day
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md); what is not
|
||||
done is listed there under *Not done*). The rule he
|
||||
chose is wider than the one first written here: `From:` is held to exactly
|
||||
what the envelope is held to - any address of the application's domain, or
|
||||
the addresses of its list - and anything else is refused, a stranger's domain
|
||||
included. The check is a role of the panel on a strict socket of its own:
|
||||
while the panel does not answer, mail is deferred, not accepted unchecked.
|
||||
Server › Health counts what that costs, to decide later whether the check
|
||||
needs a process of its own.
|
||||
|
||||
**Done when:** the e2e stand shows an application refused for another
|
||||
tenant's `From:`, for the hostname's and for an address off its list, and
|
||||
accepted for its own.
|
||||
|
||||
**Version:** shipped `2.4.0` (2026-10-12), MINOR. What it costs - the
|
||||
panel's downtime is now mail's - is in [security.md](security.md) § Accepted
|
||||
risks.
|
||||
|
||||
---
|
||||
|
||||
@@ -482,3 +639,239 @@ explicit major). Squashing the current short chain is not a reason to cut 2.x on
|
||||
its own. A missed gate leaves a mid-chain 1.x database silently stuck.
|
||||
**Version:** `2.x` major only; not a 1.x item.
|
||||
|
||||
---
|
||||
|
||||
## Dropped, with the condition for return
|
||||
|
||||
Items the owner took off the roadmap. Each names what would bring it back;
|
||||
without that it is not offered again.
|
||||
|
||||
- **structured-logging** (dropped 2026-10-13) - move the panel from
|
||||
`log.Printf` to `log/slog` with levels and fields, one logger passed through
|
||||
constructors. Dropped because the panel's log has one reader, the owner, by
|
||||
eye in `docker logs`; the change is wide (some 225 call sites), changes no
|
||||
behaviour, and gives a new format to everything that reads the output.
|
||||
**Returns if:** the log goes to an outside collector, or it has to be
|
||||
filtered by level.
|
||||
- **Certificate warnings measured by the certificate's lifetime** (left as
|
||||
it is, owner, 2026-10-14). The panel warns 14 and 3 days before a
|
||||
certificate expires, whatever its lifetime
|
||||
([notify.go](../internal/notify/notify.go), `certFirstDays`,
|
||||
`certLastDays`). A certificate issued for less than 14 days is inside the
|
||||
first warning from its first hour, and each renewal of it is a new
|
||||
certificate and a new message. Not reachable with the 90-day certificate
|
||||
the instance has. **Returns if:** the instance takes a certificate that
|
||||
lives less than 30 days. The rule then offered: the first warning at a
|
||||
quarter of the lifetime left, the last at a tenth, and never earlier than
|
||||
the 14 and 3 days of today - so a longer certificate, a yearly one
|
||||
included, is warned about exactly as now.
|
||||
- **contributing** (dropped 2026-10-13) - a `CONTRIBUTING.md` in the root:
|
||||
the dev loop, the checks before a PR, the commit protocol. Dropped because
|
||||
there is one developer and no PRs, and everything the file would say is in
|
||||
[development.md](development.md); a second file is a second copy to keep
|
||||
in step. **Returns if:** the first outside PR arrives, or the owner decides
|
||||
to invite contributors.
|
||||
- **A check that ports 25, 465 and 587 are reachable** (dropped 2026-10-13,
|
||||
from the former *preflight*). The panel can only dial its own public
|
||||
address from inside the container: behind NAT without hairpin that shows an
|
||||
open port as closed, and with hairpin it shows as open a port the provider
|
||||
blocks from outside. A card that is wrong both ways is worse than none, and
|
||||
an honest check needs a probe outside the server, which is outside the
|
||||
product ([product.md](product.md)). The administrator's browser cannot do
|
||||
it either: 25, 465 and 587 are on the Fetch standard's list of blocked
|
||||
ports. Two substitutes were offered and declined the same day: a Health
|
||||
line "last connection from outside on each port", read from `mail.log`, and
|
||||
a command in the help to run from one's own machine. **Returns if:** the
|
||||
owner accepts an outside probe, or asks for one of the two substitutes.
|
||||
- **preflight as a page of its own** (dropped 2026-10-13): see
|
||||
[health-setup-checks](#health-setup-checks). Its plan,
|
||||
`plans/preflight.md`, was removed and stays in git history.
|
||||
- **csrf-tokens** (decided 2026-10-13) - session-bound tokens on every form.
|
||||
Replaced by [origin-strict](#origin-strict); the conditions for building
|
||||
tokens after all are in [security.md](security.md), ADR § *CSRF via origin
|
||||
checking*.
|
||||
|
||||
---
|
||||
|
||||
## Ideas, not on the roadmap
|
||||
|
||||
Thoughts kept so they are not lost. They are not candidates: nothing here is
|
||||
waiting for an OK, has a version or takes a place in the order above. An idea
|
||||
joins the index only when the owner says so.
|
||||
|
||||
- **Inbound quarantine** (moved here by the owner on 2026-10-12: "an idea to
|
||||
think about") - hold suspicious inbound mail, review it in the panel, then
|
||||
release it to the upstream or discard it. It would take the relay from
|
||||
passing mail on to storing it for a while, against "no mailboxes" in
|
||||
[product.md](product.md). Notes and open questions:
|
||||
[plans/inbound-quarantine.md](plans/inbound-quarantine.md); the screens
|
||||
`in-quarantine` and `in-quarantine-message` are drawn and stay unbuilt.
|
||||
|
||||
## dns-record-collapse
|
||||
|
||||
**Problem (owner, 2026-10-15):** a DNS record block - the DKIM, SPF and
|
||||
DMARC records on an outbound domain's page, the server's records on
|
||||
Server › Settings, the MX of an inbound domain - shows the host, the type
|
||||
and the value to publish whether or not the record is already right. When
|
||||
DNS holds the record as it should be, the values are of no use on the page:
|
||||
they only push the records that need work further down.
|
||||
|
||||
**Goal:** a record whose check is *ok* is drawn folded - its name and its
|
||||
tag, one line - and opens on request to show the host, type and value (to
|
||||
copy them again, or to compare). A record that is missing, wrong or not
|
||||
checked yet stays open, as it is now, with *In DNS now* where it differs.
|
||||
|
||||
**Notes:** the block is the one `dns_record` partial (components.html), so
|
||||
the change is made once; it is a design change, mockup first (the kit's
|
||||
dns_record section and the four pages that draw records), wording
|
||||
included. No JavaScript needed for the folding itself: `<details>` opens
|
||||
and closes without it under the panel's CSP. Whether a fold remembers being
|
||||
opened across the polls of a page, and whether *Re-check DNS* folds a record
|
||||
that has just turned right, are for the mockup.
|
||||
|
||||
## notification-mail-recheck
|
||||
|
||||
**Problem (owner, 2026-10-15):** Server › Settings shows the DNS records of
|
||||
the server's own name in its *Notification mail* box (DKIM and SPF, with
|
||||
their tags), and nothing on that page says how to check them again after
|
||||
publishing. The way exists elsewhere: *Re-check DNS* on Server › Health
|
||||
re-runs the server's checks, these records' included
|
||||
(`HandleHealthRecheck` → `serverMail(true)`), and Health has a Notification
|
||||
mail card of its own - but from Settings it cannot be found.
|
||||
|
||||
**Goal:** the records can be checked again from where they are shown - a
|
||||
*Re-check DNS* action in the box's head, or at least the box saying where
|
||||
the check is and how long a result is kept. Design change, mockup first;
|
||||
decide together with [dns-record-collapse](#dns-record-collapse) (a record
|
||||
that turns right on the re-check folds).
|
||||
|
||||
## own-mta-builds
|
||||
|
||||
**Question (owner, 2026-10-15):** is it worth patching Postfix and OpenDKIM
|
||||
and shipping our own builds of them, to optimise something in SelfPost or to
|
||||
lift a limit we work around? Weigh it, for and against; nothing is agreed
|
||||
beyond the analysis.
|
||||
|
||||
**For the analysis** (starting points, none of them weighed yet):
|
||||
|
||||
- *Limits met so far* - OpenDKIM logs through syslog(3) only, hence the
|
||||
syslog relay (audit 2026-10-12, L30); the panel reads Postfix's state from
|
||||
`mail.log` lines and `postconf`/`postqueue` output; the sender check reads
|
||||
From: the way OpenDKIM does rather than asking it; the image is held to
|
||||
what Debian bookworm packages (Postfix 3.7.11, OpenDKIM 2.11.0~beta2, a
|
||||
beta that upstream has not followed with a release).
|
||||
- *For own builds* - such limits lifted at the source; newer upstream
|
||||
versions than the distribution's; options compiled in or out.
|
||||
- *Against* - security fixes become ours to follow and rebuild (Debian's
|
||||
security team does it now); longer and less reproducible image builds; a
|
||||
patch set to carry across upstream versions by one maintainer; the docs'
|
||||
habit of citing upstream source by version would have to cite ours.
|
||||
- *Middle ways to compare* - an unpatched upstream build only where the
|
||||
distribution's version is the limit; a patch sent upstream instead of
|
||||
carried; replacing OpenDKIM with signing in the panel's own milter (Go),
|
||||
which the panel already runs for the sender check.
|
||||
|
||||
**Outcome expected:** a recommendation with costs - which limits a build of
|
||||
our own would remove, what each costs to keep, and whether a middle way
|
||||
gets the same for less.
|
||||
|
||||
**Outcome (2026-10-15):** [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md).
|
||||
No own builds: no limit of Postfix's needs a patch (the panel reads it
|
||||
through the interfaces it offers), and every limit worked around is
|
||||
OpenDKIM's - a project with no release since 2018, three CVEs of 2026 open
|
||||
in every Debian suite and no one answering. What the question pointed at
|
||||
is answered by three rows instead, in this order:
|
||||
[port25-bare-newline](#port25-bare-newline) (one line, now),
|
||||
[base-trixie](#base-trixie) (bookworm is in LTS since 2026-07-12 and its
|
||||
Postfix 3.7.11 lacks the three 2026 security batches, which carry no CVE
|
||||
ids and so will not come), and
|
||||
[panel-dkim-signing](#panel-dkim-signing) (signing in the panel's own
|
||||
milter; OpenDKIM, the syslog relay and the parser port go). The owner
|
||||
agreed to the three and their order on 2026-10-15.
|
||||
|
||||
## port25-bare-newline
|
||||
|
||||
**Finding (own-mta-builds, 2026-10-15):** Postfix 3.7.11 has the 2023
|
||||
SMTP-smuggling parameter `smtpd_forbid_bare_newline` with the default `no`
|
||||
(`src/smtpd/smtpd.c:807`, "Postfix < 3.9: no"; 3.9 made `normalize` the
|
||||
default and the 3.7 `RELEASE_NOTES` recommend it). The inbound relay's
|
||||
`smtp/inet` block in `build/postfix-config.sh` does not set it, so with
|
||||
`INBOUND_RELAY_ENABLE=true` a bare `<LF>.<LF>` inside DATA ends the
|
||||
message early and what follows is read as the next transaction. Ports 465
|
||||
and 587 are not the case that matters: there the smuggled transaction is
|
||||
still the authenticated session's, under its bindings and milters.
|
||||
|
||||
**Goal:** `smtp/inet/smtpd_forbid_bare_newline=normalize`, an e2e check on
|
||||
port 25 (a bare-LF DATA, one message delivered, nothing smuggled), a
|
||||
PATCH release. [base-trixie](#base-trixie) would bring the default, but
|
||||
the relay should not wait for it.
|
||||
|
||||
## base-trixie
|
||||
|
||||
**Problem (own-mta-builds, 2026-10-15):** the image's base,
|
||||
`debian:bookworm-slim`, left regular security support on 2026-07-12 (LTS
|
||||
until 2028-06-30). Its `postfix 3.7.11-0+deb12u1` is code of mid-2024;
|
||||
upstream's three security batches of 2026 (June: unbounded SMTP inputs;
|
||||
August: BDAT memory exhaustion, a DATA crash, a policy bypass; September:
|
||||
smuggling behind a proxy filter, a Milter-reachable over-read) carry no
|
||||
CVE ids, and the LTS team works from CVE ids - no bookworm upload in 2026.
|
||||
Trixie's `postfix 3.10.13-0+deb13u1` has the August batch; 3.10.14 with
|
||||
September's is upstream and trixie-security follows within weeks.
|
||||
|
||||
**Goal:** `debian:trixie-slim` and `golang:1.26-trixie`; every citation of
|
||||
`3.7.11` (331 lines in 65 files) re-read against the 3.10.13 tree, with
|
||||
attention to 3.10's reject logging (`sasl_method`, `sasl_username`,
|
||||
`sasl_sender` on the line), the queue id on an abnormal connection end,
|
||||
cleanup's `canceled` line, 3.8's `postfix(1)` stderr logging under
|
||||
supervisord; `dev/upstream` refetched from trixie; the cited-package table
|
||||
and `checkCitedPackageVersions`; the e2e sink image; `opendkim-tools`
|
||||
dropped from the image (used by nothing at run time). OpenDKIM's signing
|
||||
and From:-parsing code is the same in trixie (its new Debian patches touch
|
||||
the verifier and librbl), its citations stand. One stage, a MINOR release. Also
|
||||
gained: `smtpd_hide_client_session` (3.10); the owner decided on
|
||||
2026-10-15 to turn it on for 465 and 587 in the same release, so the
|
||||
`Received:` header no longer shows the application's address.
|
||||
|
||||
## panel-dkim-signing
|
||||
|
||||
**Problem (own-mta-builds, 2026-10-15):** OpenDKIM is a beta of 2018 with
|
||||
no upstream answering (CVE-2026-100888, -100889, -101277 open in every
|
||||
Debian suite, "vendor did not respond"; Debian bug #1149875 unanswered).
|
||||
Every limit SelfPost works around is its: it logs through syslog(3) only
|
||||
(the relay), it chooses the key by `From:` with its own parser (the port,
|
||||
the 256-byte refusal, the probe scripts), its socket file outlives a
|
||||
crash, its tables are reloaded by a cross-uid signal (`CAP_KILL`), its
|
||||
keys must be shared through a group.
|
||||
|
||||
**Goal:** the panel's own milter signs - `github.com/emersion/go-msgauth/dkim`
|
||||
(the e2e suite's verifier already; `relaxed/simple`, `From` over-signed,
|
||||
RFC 6376 § 5.4's header set, the body hashed as it streams), the key by
|
||||
the login's domain on 465/587 and by the server's name for the panel's
|
||||
local submission (`non_smtpd_milters` on the same socket, strict).
|
||||
Removed: the `opendkim` and `opendkim-tools` packages, `opendkim.conf`,
|
||||
two supervisord programs (OpenDKIM, the syslog relay), the table renderer
|
||||
and its reload, the parser port and its scripts, Health's OpenDKIM
|
||||
entries, probably `CAP_KILL` - about 1,500 lines. Keys, selectors and
|
||||
published DNS records unchanged; nothing for an operator to do. The e2e
|
||||
oracle must be an independent verifier (`opendkim-testmsg` in the sink
|
||||
image, or rspamd), not the signing library, and a message to a real
|
||||
mailbox precedes the cut. Two to three stages, a MINOR release; after
|
||||
[base-trixie](#base-trixie) (remote exposure first; the two touch
|
||||
disjoint citations).
|
||||
|
||||
## settings-save-row
|
||||
|
||||
**Problem (owner, 2026-10-15, a screenshot of `selfpost.mixfed.ru`):** on
|
||||
Server › Settings the *Save settings* button sits under the two columns, at
|
||||
the left, after a long gap: the left column (retention, sending rate limits)
|
||||
is short, the right one (the notifications switch, then *Notification mail*
|
||||
with its records open) is long. The button saves the retention fields and
|
||||
the switch; the records under the switch are not fields, and they are what
|
||||
pushes the button down.
|
||||
|
||||
**Goal:** the button reads as the save of the fields it saves. Either the
|
||||
gap goes by itself when [dns-record-collapse](#dns-record-collapse) folds
|
||||
the records that are right, or the page changes - the records out of the
|
||||
form's columns, or the button nearer its fields. Decide with the mockup,
|
||||
after dns-record-collapse, on a page whose records are missing as well as
|
||||
on one whose records are right.
|
||||
|
||||
+35
-11
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Chain head | `user_version = 1` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline) |
|
||||
| Chain head | `user_version = 11` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql` |
|
||||
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||
| Database file | `/data/selfpost.db` (bind mount) |
|
||||
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
|
||||
@@ -60,10 +60,16 @@ What that means in practice:
|
||||
|
||||
**Implication:** once `2.0.0` is released, `0001_init.sql` is never edited
|
||||
again and no file is deleted, renamed or reordered — every 2.x database has
|
||||
already run it. Until that release the baseline is still being written:
|
||||
later steps of the redesign and the features queued behind it (the next one
|
||||
is `0002_inbound_spam_log.sql` of inbound-antispam-panel, `2.1.0`) add files
|
||||
after it.
|
||||
already run it. `2.0.0` and `2.0.1` shipped with the baseline alone; the
|
||||
features after them add files, the first being `0002_inbound_spam_log.sql` of
|
||||
inbound-antispam-panel (`2.1.0`), then `0003_send_log_lines.sql` of
|
||||
delivery-log-storage (`2.2.0`), `0004_notifications.sql` and
|
||||
`0005_send_log_rejected_index.sql` of panel-notifications (`2.3.0`), and
|
||||
`0006_sender_check.sql` of from-header-binding (`2.4.0`), and
|
||||
`0007_delivery_log_days.sql` and `0008_tenant_boundary.sql` of the 2026-10-12
|
||||
audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
|
||||
`0010_send_log_indexes.sql` (`2.5.3`) and `0011_notify_addressees.sql`
|
||||
(`2.5.4`).
|
||||
|
||||
---
|
||||
|
||||
@@ -72,25 +78,35 @@ after it.
|
||||
| Ver | File | Shipped | Kind | Summary |
|
||||
|-----|------|---------|------|---------|
|
||||
| 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below |
|
||||
| 2 | `0002_inbound_spam_log.sql` | 2.1.0 | DDL | `inbound_spam_log`, `inbound_spam_list` and two indexes; nothing existing changes |
|
||||
| 3 | `0003_send_log_lines.sql` | 2.2.0 | DDL | `send_log_lines` and one index; nothing existing changes |
|
||||
| 4 | `0004_notifications.sql` | 2.3.0 | DDL | `users.notify` (default 1: every user starts with notifications on) and `notify_state` |
|
||||
| 5 | `0005_send_log_rejected_index.sql` | 2.3.0 | DDL | `idx_send_log_rejected`, a partial index of the refused rows of `send_log` by time; a file of its own because the test installation had run 0004 before the review asked for it |
|
||||
| 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` |
|
||||
| 7 | `0007_delivery_log_days.sql` | 2.5.0 | DDL | `delivery_log_days`: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). `send_log.reason` gains the values `reconcile` and `reconcile-contradicted` without a schema change |
|
||||
| 8 | `0008_tenant_boundary.sql` | 2.5.0 | DDL + data | `idx_applications_login_nocase`: an application login is unique without regard to case (audit 2026-10-12, H4). `send_log.domain_id` and `inbound_spam_log.inbound_domain_id` (`ON DELETE SET NULL`), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) |
|
||||
| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
|
||||
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
|
||||
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
|
||||
|
||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||
for operators upgrading from older 2.x images.
|
||||
|
||||
---
|
||||
|
||||
## Schema at head (v1)
|
||||
## Schema at head
|
||||
|
||||
| Table | Role |
|
||||
|-------|------|
|
||||
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
|
||||
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
|
||||
| `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
|
||||
| `user_domains` | Outbound domains assigned to a `domain` user |
|
||||
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
|
||||
| `application_addresses` | Explicit From addresses (`list` mode) |
|
||||
| `rate_limits` | Level-2 limits per domain / application, manual or auto |
|
||||
| `send_log` | Delivery journal |
|
||||
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
|
||||
| `logtail_state` | Log-tailer read position |
|
||||
| `inbound_domains` | Inbound relay domains |
|
||||
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
|
||||
@@ -98,9 +114,17 @@ for operators upgrading from older 2.x images.
|
||||
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
|
||||
| `dmarc_reports` | Parsed aggregate report summaries |
|
||||
| `dmarc_report_records` | Per-source rows inside a report |
|
||||
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
|
||||
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
|
||||
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
|
||||
| `sender_check_days` | What the sender check costs, a row per UTC day that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
|
||||
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per UTC day, kept a year. Read off a copy of the database; no page shows it (v7) |
|
||||
| `notify_addressees` | Who a subject's notification is owed to and whether each was handed it; goes with the subject's `notify_state` row (v11) |
|
||||
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
|
||||
|
||||
`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
|
||||
and index of this list.
|
||||
and index of this list; `TestOpenMigratesA20Database` opens a database that
|
||||
holds the baseline alone.
|
||||
|
||||
### What changed against the last 1.x schema
|
||||
|
||||
@@ -129,7 +153,7 @@ for the domain. Nothing refers to a user: deleting one changes no domain.
|
||||
|
||||
1. Add `internal/store/migrations/00NN_short_name.sql` (next number).
|
||||
2. Update the table in [Migration chain](#migration-chain), the snapshot at the
|
||||
top, and the affected rows of [Schema at head](#schema-at-head-v1).
|
||||
top, and the affected rows of [Schema at head](#schema-at-head).
|
||||
3. Update `TestBaselineSchema` if tables, columns or indexes change, and add a
|
||||
test that opens a database at the previous version and migrates it.
|
||||
4. A *data* migration states in a comment which 2.x versions it is written for.
|
||||
|
||||
+380
-30
@@ -26,7 +26,33 @@ the user form did not end that user's sessions, internal errors reached forms a
|
||||
(see [Authentication and sessions](#authentication-and-sessions) and [Errors
|
||||
shown to users](#errors-shown-to-users)); two deliberate behaviours are
|
||||
recorded under Accepted risks (upstream of a delegated inbound domain, profile
|
||||
saved without the password).
|
||||
saved without the password). The 2026-10-11 review (Fable) covered the DMARC
|
||||
report path from port 25 to the report pages, which ran for the first time in
|
||||
2.0.0 - the 2026-08-19 pass read code that Postfix could not yet start: no
|
||||
Critical or High finding; a report's size was bounded only as bytes (now 5 000
|
||||
records, 255 bytes a field), the retention cap was one for the whole server
|
||||
(now per domain), the delivery command could run a hundred at once (now one),
|
||||
and the reason logged for a dropped message carried the sender's bytes as they
|
||||
came (now cut and reduced to printable ASCII). The 2026-10-12 reviews of
|
||||
inbound-antispam-panel (`2.1.0`: the inbound journal milter, refusals read from
|
||||
`mail.log`, the filter lists and their pages) were two, by different models
|
||||
over the same diff. The technical one (Opus) found, outside the feature and
|
||||
there since `1.9.0`, that **the level-2 rate limit and an application's
|
||||
client-address list held only for the first message of a connection** - see
|
||||
[Mail path](#mail-path-security-relevant) - which was reproduced on the test
|
||||
stand and fixed. The security one (Fable) found no Critical or High: a client's
|
||||
HELO name could supply the sender, recipient and reply of a journal row, as the
|
||||
parser then read the line, and so make a refusal look like the deny list's
|
||||
(Postfix itself logs a HELO name with a space, `<>()\";@` and non-printable
|
||||
characters turned into `?`; the reply now ends at Postfix's own `; from=<`);
|
||||
the journal's row cap was applied only every six hours, between which
|
||||
refusals were written at whatever rate a stranger sent recipients (the
|
||||
tailer now cuts to the cap every 500 rows); and two behaviours are recorded
|
||||
under Accepted risks (the allow list keyed on an address anyone can claim, the
|
||||
list files readable by anyone). A third it named - with the filter down, a
|
||||
sender's own verdict headers were recorded as the filter's - was closed the
|
||||
same day at the owner's suggestion: the filter signs each message and a
|
||||
verdict is read only next to its signature.
|
||||
(2) **Accepted risks** —
|
||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||
lost.
|
||||
@@ -64,8 +90,10 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
|
||||
- The panel **generates** the password on creation or reissue and shows it
|
||||
**once**.
|
||||
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||
panel); a lost password can only be reissued.
|
||||
- In `sasldb2` it is stored in plaintext - Cyrus keeps `userPassword` as given
|
||||
(Cyrus SASL 2.1.28, lib/server.c `sasl_setpass()`), and a domain export reads
|
||||
it back from there. The panel never shows it again: a lost password is
|
||||
reissued. `sasldb2` is as secret as the exports (§ Backup and domain export).
|
||||
|
||||
### Input and configuration
|
||||
|
||||
@@ -76,10 +104,19 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||
input; arguments are passed as separate elements.
|
||||
- Writes to config files are escaped (no injection of Postfix directives).
|
||||
- The panel's HTTP server bounds every phase a client could hold it in: the
|
||||
header 10 s, the request 30 s, the answer 60 s, an idle kept-alive
|
||||
connection 2 min; a full backup download moves its own write limit to
|
||||
two hours (`2.5.4`).
|
||||
|
||||
### Authentication and sessions
|
||||
|
||||
- Rate limiting on login (per IP, with lockout or delay).
|
||||
- Rate limiting on login (per IP, with lockout or delay). The Account form's
|
||||
current-password check has a limit of its own, ten in 15 minutes per user,
|
||||
so guesses there do not lock the address out of signing in (`2.5.4`).
|
||||
- An unknown username is refused in the time a wrong password is: the sign-in
|
||||
compares a bcrypt hash of the panel's work factor either way, so the delay
|
||||
does not tell which names exist (`2.5.4`).
|
||||
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||
`SameSite`.
|
||||
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||
@@ -92,7 +129,9 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
- **What ends a user's sessions:** deleting the user (all); a password change on
|
||||
Account (that user's *other* sessions — never another user's); a password set
|
||||
for them by a global user on the user form (all of theirs, except the current
|
||||
one when they set their own); signing out (the current one only). There is no
|
||||
one when they set their own); signing out (the current one only); signing
|
||||
in (the session the browser presented, if any - a token planted before the
|
||||
sign-in is worth nothing after it, `2.5.4`). There is no
|
||||
"sign out everywhere", and a restored backup brings back the session rows it
|
||||
held.
|
||||
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
|
||||
@@ -113,6 +152,8 @@ assigned outbound domains and inbound domains **separately**, each list with
|
||||
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
|
||||
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
|
||||
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
|
||||
| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by id: not those of an earlier domain of the same name), without the links into the filter lists |
|
||||
| Inbound › Filter lists; the inbound log's retention | yes | no — 404 |
|
||||
| Add or delete a domain, either direction; import a domain | yes | no |
|
||||
| Account, Help | yes | yes |
|
||||
|
||||
@@ -126,7 +167,17 @@ assigned outbound domains and inbound domains **separately**, each list with
|
||||
(`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment
|
||||
in the query. Each denied path has a test for a `domain` user, including
|
||||
another tenant's id.
|
||||
- The panel will not remove or demote the last `global` user.
|
||||
- The panel will not remove or demote the last `global` user. The guard is
|
||||
inside the transaction that deletes or demotes: the write comes first and
|
||||
the count after it, so two administrators deleting or demoting each other
|
||||
at the same moment cannot both succeed; first-user setup is one statement
|
||||
that inserts only into an empty table. A user's name, password, e-mail,
|
||||
role and assignments are saved in one transaction, so a failure part way
|
||||
leaves no domain user with nothing assigned.
|
||||
- A log row - outbound or inbound - belongs to a domain by its id, written
|
||||
when the row is: the outbound row's from the application that sent it. A
|
||||
domain deleted and added again starts with an empty log for whoever it is
|
||||
assigned to; the rows of the deleted one are the global role's alone.
|
||||
- A `domain` user can export an assigned domain, and an export carries the
|
||||
working application passwords (encrypted unless the box is unticked): assign
|
||||
domains with that in mind.
|
||||
@@ -138,8 +189,10 @@ assigned outbound domains and inbound domains **separately**, each list with
|
||||
- Every non-read request first passes the origin check
|
||||
(`originAllowed`, [internal/web/security.go](../internal/web/security.go)):
|
||||
`Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own
|
||||
host; the session cookie is `SameSite` as well.
|
||||
- There are **no CSRF tokens yet**. The decision, its price and the conditions
|
||||
host (the panel's `Referrer-Policy` is `same-origin`, so a browser puts the
|
||||
panel's own origin on a form post of the panel); the session cookie is
|
||||
`SameSite` as well.
|
||||
- There are **no CSRF tokens**. The decision, its price and the conditions
|
||||
for revisiting it are in the accepted risks and the ADR below.
|
||||
|
||||
### Errors shown to users
|
||||
@@ -156,12 +209,59 @@ operator can act on it.
|
||||
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||
journal, themes).
|
||||
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||
is granted through the `selfpost` group with minimal permissions.
|
||||
is granted through the `selfpost` group with minimal permissions. The
|
||||
supervisord control socket, which can stop or restart any program, is the
|
||||
`supervisor` group's, whose one member is the panel; OpenDKIM and Postfix
|
||||
share `selfpost` for files and do not reach it (`2.6.0`).
|
||||
|
||||
### Mail path (security-relevant)
|
||||
|
||||
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
- **A recipient does not learn where an application runs (`2.7.0`).** The
|
||||
`Received:` header Postfix adds on 465 and 587 carries no client name,
|
||||
address, HELO name, TLS details or login (`smtpd_hide_client_session=yes`,
|
||||
the owner's decision of 2026-10-15): before, every message told its
|
||||
recipients the address and the internal host name of the machine the
|
||||
application sent from. What a complaint needs stays with the operator - the
|
||||
queue id in the header, the panel's log and smtpd's `client=` line in
|
||||
mail.log. Port 25 keeps the full header, as RFC 5321 requires of an MTA.
|
||||
- **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the
|
||||
key by the `From:` header and Postfix holds only the envelope sender
|
||||
against the login, so until `2.4.0` an application of one sending domain
|
||||
could write another registered domain - or, since `2.3.0`, the server's
|
||||
own name - into `From:` and have the message signed with that name's key
|
||||
(found by the security review of panel-notifications, 2026-10-12). The
|
||||
panel's sender check now holds the header to the table the envelope is
|
||||
held to and refuses anything else with `550 5.7.1`, a domain the server
|
||||
does not sign for included (the owner's rule: the header may say what the
|
||||
envelope may, nothing more). It is the first milter of the chain and
|
||||
strict: a message it could not judge is deferred, never passed on to the
|
||||
signer. Since `2.5.2` it reads the header with OpenDKIM's own parser,
|
||||
`dkim_mail_parse()` ported line for line from the opendkim the image runs
|
||||
(`internal/milter/mailparse.go`), and judges the address that parser
|
||||
returns - the address OpenDKIM signs for - whatever a display name, a
|
||||
comment or a quoted local part holds; one `From:` field and one address in
|
||||
it stay required, and an address longer than the 256 bytes OpenDKIM
|
||||
matches its `SigningTable` against is refused, since cut there it could end
|
||||
in another domain's name (opendkim.c, `dkimf_apply_signtable()`,
|
||||
`MAXADDRESS`). That the port is OpenDKIM's parser is tested against the C
|
||||
itself, two hundred thousand lines and every result alike; that the signed
|
||||
domain follows, by the e2e: it sends every `From:` line the check passes (a
|
||||
list shared with the unit test,
|
||||
`internal/milter/testdata/from-lines-passed.txt`) and wants it signed for
|
||||
the application's domain and no other, and every
|
||||
line of a second list naming another sender of the server in a quoted
|
||||
string, a comment, a quoted pair, capitals or an encoded word
|
||||
(`from-lines-other.txt`) must be refused or arrive signed for the
|
||||
application's domain, never for that sender. What this does not promise is
|
||||
what a display name looks like to a person: a name of another sender in
|
||||
it passes, signed with the application's own key - a forged display name is
|
||||
as old as mail, and no key of another's is involved. What the client wrote
|
||||
reaches the reply, the log and the send log only as printable ASCII, cut,
|
||||
and a refused message is one row of the log whatever its recipient count.
|
||||
Mail the panel submits itself does not pass the check: only root,
|
||||
`postfix` and the panel can submit locally. Architecture § Sender check.
|
||||
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
|
||||
open relay either: SASL is off; `smtpd_relay_restrictions` /
|
||||
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
|
||||
@@ -170,7 +270,60 @@ operator can act on it.
|
||||
is never accepted with nowhere to send it. Prefer recipient mode `list` to
|
||||
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
|
||||
on inbound. An optional antispam milter is inbound-only; default action is
|
||||
fail-open (`accept`).
|
||||
fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the
|
||||
standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the
|
||||
2023 SMTP-smuggling defence, which Postfix 3.7 left off by default): a
|
||||
`.` line next to a bare newline is body, and what a smuggler would put
|
||||
behind it is delivered as part of the one message, not read as a second
|
||||
transaction. The submission ports keep the default. Since `2.7.0` the
|
||||
image runs Postfix 3.10.13, whose default is `normalize` too (Postfix 3.9
|
||||
and later; `src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`), so
|
||||
they end DATA the same way; the port-25 setting stays explicit. Before
|
||||
`2.7.0` a smuggled transaction on a submission port was still the
|
||||
authenticated session's, under its sender bindings and milters
|
||||
([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
|
||||
- **The panel's milters judge every message of a connection.** Postfix
|
||||
introduces a client to a milter once per connection, and the milter library
|
||||
replaces the milter's session after any reply that is not "continue". Until
|
||||
`2.1.0` the journal milter knew the client's address only from that
|
||||
introduction, and both of its checks passed a message that came without one:
|
||||
a second `MAIL FROM` after a refused one, or any message after the first on
|
||||
one connection, got past the level-2 limit and past an application's
|
||||
client-address list. Now the address comes with every `MAIL FROM`
|
||||
(`milter_mail_macros` carries `{client_addr}`), the level-2 limit does not
|
||||
ask for it at all, an application with a client-address list is refused when
|
||||
no address is known, and neither milter replies anything but "continue" to a
|
||||
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
|
||||
- **The inbound journal is written from what strangers send.** The inbound
|
||||
journal milter is the last on port 25, read-only and attached fail-open; it
|
||||
keeps of the filter's verdict only what its patterns recognise. **A verdict
|
||||
is read only next to the filter's signature.** The filter is attached so
|
||||
that mail flows when it is down, and then nothing removes verdict headers a
|
||||
sender wrote himself. Nor would rspamd itself by default for a client it
|
||||
counts as local - a private address, which every client has when Docker's
|
||||
proxy relays port 25 - or an authenticated one: `skip_local` and
|
||||
`skip_authenticated` (rspamd 3.11.1, `milter_headers.lua`, `skip_wanted()`)
|
||||
leave the sender's headers in place, while the signing routine below runs
|
||||
regardless; both are turned off in `milter_headers.conf` (assumptions
|
||||
C-R7, tried on 2026-10-14). So rspamd adds a third header, `X-SelfPost-Filter`:
|
||||
the HMAC-SHA256 of the message's queue id under a key SelfPost generates
|
||||
(`data/antispam/filter.key`) and the sidecar reads from the directory it
|
||||
mounts. The journal milter recomputes it at end-of-message; with no
|
||||
signature, or one that does not fit, the row is that of mail nobody judged.
|
||||
The signature goes on to the upstream with the mail and is of no use there:
|
||||
it fits one queue id. Refusals are
|
||||
parsed from `mail.log` from the head of the line, with the reply ending at
|
||||
Postfix's own `; from=<` so that the HELO name after it - the client's text,
|
||||
which Postfix logs with non-printable characters and ` <>()\";@` turned into
|
||||
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
|
||||
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept.
|
||||
Only a recipient at an inbound domain makes a row. The journal is cut to
|
||||
10 000 rows every 500 rows the tailer writes, and by age every six hours.
|
||||
- **Filter lists** reach another container as files: values are whitelisted on
|
||||
the way in (address, domain, canonical IP or network; no `/0`, no
|
||||
IPv4-mapped network), the writer refuses a stored value with whitespace, a
|
||||
newline or `#`, and a change of the table and the rewrite of the six files
|
||||
are one step under a lock.
|
||||
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||
`encrypt`).
|
||||
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||
@@ -183,12 +336,44 @@ operator can act on it.
|
||||
private key from `certs/` when present; a domain export carries the DKIM key
|
||||
and **working** application passwords in the clear (otherwise a transfer
|
||||
without recreating credentials would be impossible).
|
||||
- Links under `certs/` (and `certs` itself as a link) are followed, in the
|
||||
container's view: the archive carries whatever regular file they lead to
|
||||
that the panel's user may read. `certs/` is the operator's, mounted
|
||||
read-only; only someone who can write the project directory places a link
|
||||
there, and only a global user can download the archive (`2.5.2`).
|
||||
What a backup still leaves out - a link under `data/`, a link under
|
||||
`certs/` to a directory - it names in a warning, and a `certs/` with no
|
||||
file in it is refused rather than archived empty (`2.5.3`).
|
||||
- A backup is made only of the database that is there: a wrong
|
||||
`SELFPOST_DB_PATH` is refused, and the snapshot opens the source
|
||||
read-only. An archive of an empty database would restore a server that
|
||||
offers its first-run setup - a new global administrator - while it holds
|
||||
live DKIM keys and `sasldb2` (`2.5.3`).
|
||||
- The first start after a restore opens nothing until `/data` matches the
|
||||
archive's last entry, `data/contents.json` (size and SHA-256 of each file
|
||||
under `data/`): an archive unpacked in part - its manifest there, half its
|
||||
database - is refused (`2.5.3`). The check is integrity, not authenticity:
|
||||
whoever can write the project directory can write that record too. It
|
||||
reads only regular files at the recorded names (a link or a pipe there is
|
||||
refused, not followed), refuses a database journal file beside the
|
||||
restored database, and is made once per restore: a start after one that
|
||||
passed it and failed is not compared again while the manifest is the same.
|
||||
- Since 2.2.0 a full backup also carries, in the database, what Postfix logged
|
||||
about each message still in the send log (`send_log_lines`): the submitting
|
||||
client's address and SASL login, the message id, the receiving server and
|
||||
its reply. `mail.log` itself stays out of a backup; these lines are state
|
||||
the panel shows, kept for the send log's retention and no longer.
|
||||
- Both downloads are encrypted with a password unless the box on the form is
|
||||
unticked (it is ticked by default):
|
||||
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||
a truncated or substituted file fails to open instead of silently restoring a
|
||||
partial "tail". Format and wrapper:
|
||||
partial "tail", and bytes after the last chunk are refused (`2.5.3`). The
|
||||
key-derivation parameters travel in the header, so a reader is bounded in
|
||||
what a header may ask before the password is checked: scrypt's array at
|
||||
most 128 MiB (four times what SelfPost writes), r ≤ 64 so the rest stays
|
||||
under 32 KiB, p ≤ 2 - *Import a domain* reads an uploaded file, and the
|
||||
old cap let a header ask for 4 GiB (`2.5.3`). Format and wrapper:
|
||||
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||
@@ -199,7 +384,13 @@ operator can act on it.
|
||||
`-password-file`, never as an argument (the process list is readable by any
|
||||
process in the container).
|
||||
- Minimum password length matches the administrator password (12): the file
|
||||
sits offline and can be attacked without a time limit.
|
||||
sits offline and can be attacked without a time limit. The panel's forms and,
|
||||
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
|
||||
any.
|
||||
- The CLI's `-o FILE` is written beside its name and takes it only when
|
||||
whole, so a failure - a wrong password to `-decrypt` among them, which is
|
||||
checked on the first chunk before the output is touched - leaves the file
|
||||
that was there (`2.5.3`).
|
||||
|
||||
---
|
||||
|
||||
@@ -216,7 +407,9 @@ deferred item from the roadmap.
|
||||
against, and a strict mode would not "protect" such a client, it would
|
||||
simply break the panel in it. Tightening is one line in `originAllowed`
|
||||
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||
instead of `true` in the "neither header present" branch.
|
||||
instead of `true` in the "neither header present" branch. That tightening
|
||||
is agreed (owner, 2026-10-13) as
|
||||
[origin-strict](roadmap.md#origin-strict); this bullet goes when it ships.
|
||||
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||
@@ -225,10 +418,12 @@ deferred item from the roadmap.
|
||||
(see the ADR below). A token would not save the panel from XSS inside it
|
||||
either: code executing in the panel's origin sends the request itself —
|
||||
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||
templates must contain no inline scripts and no inline styles. Tracked as
|
||||
[csrf-tokens](roadmap.md#csrf-tokens): implementing that item replaces this
|
||||
bullet and the ADR below, and dropping it leaves them as the single record of
|
||||
the decision. The two must not disagree.
|
||||
templates must contain no inline scripts and no inline styles. Decided by
|
||||
the owner on 2026-10-13: no tokens; the roadmap item that asked for them is
|
||||
dropped and the origin check is made strict instead
|
||||
([origin-strict](roadmap.md#origin-strict)). This bullet and the ADR below
|
||||
are the single record of the decision, and the ADR's **Revisit if** says
|
||||
when tokens are built after all.
|
||||
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||
`data-confirm` prompt handled entirely in
|
||||
@@ -274,24 +469,78 @@ deferred item from the roadmap.
|
||||
reporters are arbitrary third parties and the mailbox address is published in
|
||||
DNS. The ingest already constrains the meaningful part — a report cannot be
|
||||
filed under a domain this instance does not host, and the claimed domain is
|
||||
cross-checked against the `+tag` of the delivery address — and the data is
|
||||
advisory statistics only; nothing enforces off it. The trigger to revisit is
|
||||
cross-checked against the `+tag` of the delivery address (effective since
|
||||
2.0.0: before it the recipient never reached the command) — and the data is
|
||||
advisory statistics only; nothing enforces off it. What a stranger can do is
|
||||
limited to a domain that chose a SelfPost-hosted address, through that
|
||||
address: file reports under it, replace one whose reporter and id they know,
|
||||
and fill that domain's 500 — not another domain's. A message that is not a
|
||||
report the relay keeps is dropped, not bounced (the envelope sender is the
|
||||
author's to write); a genuine report the parser cannot read is lost the same
|
||||
way, with a count and a line in the mail log. The trigger to revisit is
|
||||
any feature that lets report contents drive an automatic action (policy
|
||||
changes, rate limiting, alerting an operator into a config change).
|
||||
- **The application auth-IP allow-list is enforced fail-open.** The check runs
|
||||
in the journal milter (`internal/milter/authip.go`) with
|
||||
`default_action=accept` and skips on store errors, so if the milter is down
|
||||
the restriction is not applied; the SASL login itself always succeeds. This
|
||||
`default_action=accept` and skips on store errors, so if the journal milter
|
||||
does not answer the restriction is not applied; the SASL login itself
|
||||
always succeeds. (While the milter is up the list holds for every message,
|
||||
and an unknown client address is refused - see Mail path; what is accepted
|
||||
here is the milter not answering. Since `2.4.0` a panel that is down
|
||||
altogether defers mail through the sender check, which shares its process;
|
||||
what is left of this window is a journal milter that hangs or a database
|
||||
that errs while the check beside it still answers.) This
|
||||
is documented in [guide.md](guide.md) as **not an authentication boundary** —
|
||||
it is a blast-radius limiter for a leaked application password, on the same
|
||||
fail-open path as level-2 rate limiting, and a milter outage already stops
|
||||
the journal from recording sends at all. The trigger to revisit is a
|
||||
requirement for the allow-list to hold as a security boundary — at which
|
||||
point it must move to the SASL/smtpd layer and fail closed.
|
||||
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
|
||||
The journal milter cannot observe connection-close directly through
|
||||
go-milter, so if a client disconnects without an explicit `Abort`, its
|
||||
in-memory reservation is released by TTL expiry, not immediately. The map is
|
||||
- **The key the spam filter signs with is readable by anyone on the host.**
|
||||
The inbound journal reads the filter's verdict only next to the filter's
|
||||
signature (see Mail path), and the key for it, `data/antispam/filter.key`,
|
||||
is 0644 like the list files beside it: the filter is another user in another
|
||||
container. Someone with a shell on the host could therefore sign a verdict
|
||||
of his own - into a monitoring page, on a host where he could do far more.
|
||||
Senders on the internet, whom the signature is for, cannot read it. Trigger
|
||||
to revisit: the same as for the list files - a shared host.
|
||||
- **An allow entry for a sender address or domain trusts what the sending
|
||||
server says.** The filter matches the envelope sender (only that: a
|
||||
bounce's `From:` header is not looked at, `extract_from = "smtp"`), which
|
||||
is not authenticated: anyone who knows or guesses an allowed address can send as it
|
||||
and is given the entry's weight, which no spam score outweighs. The page's
|
||||
Help and the guide say so and recommend an entry for the client's address or
|
||||
network instead. Deny entries have no such problem - claiming to be a denied
|
||||
sender only gets mail refused - and deny wins over allow. Trigger to
|
||||
revisit: allow entries being used as the main way to receive mail from a
|
||||
sender, at which point the rule should also require the sender's SPF or
|
||||
DKIM to pass.
|
||||
- **The filter lists are files readable by anyone on the host.**
|
||||
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
|
||||
the filter runs under another user in another container (rspamd as
|
||||
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`, measured on a live
|
||||
server, 2026-10-15; the panel is uid 10999 since `2.7.0`). They hold sender
|
||||
addresses, domains and networks - who the operator blocks and whom he lets
|
||||
through - but no message data. The inbound journal itself is in the
|
||||
database, which is 0600 since `2.7.0` (0644 before). Trigger to revisit: a shared host, where the sidecar should
|
||||
instead be given the files through a group it shares with the panel.
|
||||
- ~~**An application can learn which domains are senders of this server.**~~
|
||||
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
|
||||
check reads a `From:` line with OpenDKIM's own parser, ported
|
||||
(`internal/milter/mailparse.go`), and decides by the address OpenDKIM signs
|
||||
for; what a display name, a comment or a quoted local part names no longer
|
||||
matters, so refused or passed says nothing about other domains of the
|
||||
server.
|
||||
- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
|
||||
`2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
|
||||
§ Roles.
|
||||
- **Level-2 in-flight reservations can outlive a broken SMTP session until TTL.**
|
||||
Postfix tells the milters ABORT when a client drops the connection
|
||||
mid-transaction (Postfix 3.10.13, src/smtpd/smtpd.c `mail_reset()` ->
|
||||
`milter_abort()`), and the journal milter releases the reservation then.
|
||||
When that never arrives - smtpd killed, the milter connection lost - go-milter
|
||||
has no close callback, and the in-memory reservation is released by TTL
|
||||
expiry, not immediately. The map is
|
||||
bounded by key and TTL (currently 10 minutes), so the effect is temporary
|
||||
over-counting on that app/domain key under churn. Accepted deliberately:
|
||||
immediate cleanup would require protocol hooks the current milter interface
|
||||
@@ -301,9 +550,88 @@ deferred item from the roadmap.
|
||||
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||
the file is inaccessible to others. The log holds envelope addresses and
|
||||
client IPs, but neither message bodies nor headers; it is excluded from
|
||||
client IPs and SASL logins, the `Message-ID` of each message and the replies
|
||||
given - for a refused `From:` line the reply quotes it (printable ASCII,
|
||||
cut) - but no message bodies and no other headers; it is excluded from
|
||||
backups (`log/` is skipped) so that a dump stays state rather than
|
||||
diagnostics.
|
||||
diagnostics. Since 2.2.0 the tailer copies the lines of queued outbound
|
||||
messages into the database for the message's page, and those are in a
|
||||
backup (§ Backup and domain export); mail offered on port 25 is not copied.
|
||||
- **The panel submits mail of its own.** Notifications leave through
|
||||
`sendmail(1)` run by the unprivileged panel user: a fixed argument list, the
|
||||
two addresses after `--`, no shell. The recipient is a panel user's account
|
||||
e-mail, validated when it was saved and checked again before it reaches a
|
||||
header or the command line; the subject and body are composed by the panel
|
||||
from its own readings. Two of those readings can carry text from outside - a
|
||||
DNS check's finding, and a queued message's sender address - and both are
|
||||
reduced to printable ASCII and cut before they are printed, so a
|
||||
notification's body can contain an address a stranger chose, as a few plain
|
||||
characters and nothing else. A refused-mail notification names the domain
|
||||
and the applications from the registry, never the domain a client wrote in
|
||||
`MAIL FROM`: that string is not yet held against the login when the milter
|
||||
refuses, and the first build took it for the subject - an application over
|
||||
its limit could have made the panel write a message per made-up domain, to
|
||||
another tenant's users among others (security review, 2026-10-12; fixed
|
||||
before release). The panel's membership in `postdrop` is what lets it
|
||||
submit: under `no-new-privileges` the setgid bit of `postdrop` does not
|
||||
take, so only root, `postfix` and the panel can. Notifications are signed
|
||||
with a key of the server's own name and bypass the level-1 and level-2
|
||||
limits. What bounds them: the subjects are the registered domains, the
|
||||
certificate and the queue; a domain's DNS can be reported and cleared about
|
||||
a dozen times a day at most by someone who flaps the zone; a domain's
|
||||
limits once a day; and no more than 60 messages leave in any hour whatever
|
||||
the rules arrive at.
|
||||
- **The sender check stops mail while the panel does not answer.** It is a
|
||||
role of the panel process, attached with `default_action=tempfail`, so a
|
||||
panel that is down or slower than the milter timeouts (15 s to connect
|
||||
and for a command, 30 s for the verdict at the end of the headers) defers
|
||||
every submission: availability of sending now depends on the panel,
|
||||
which it did not before `2.4.0`. Chosen by the owner over the alternative,
|
||||
a check that is absent whenever the panel is - that one could be switched
|
||||
off by whoever can make the panel slow. Supervisord starts a panel that
|
||||
died again at once; one that dies within a second of starting is tried
|
||||
four times in a few seconds, and then SelfPost's `crashexit` listener takes
|
||||
the container down (supervisor 4.2.5, `startretries=3`); since `2.6.0` it
|
||||
does so too when the panel exits unexpectedly five times in ten minutes,
|
||||
however long each run lasted - so five malformed packets on a milter
|
||||
socket (the next item) restart the whole container. The outage is
|
||||
seconds or the whole instance; Server › Health counts the
|
||||
downtime and the mail deferred for it. Trigger to revisit: those numbers
|
||||
not staying at nothing - then the check moves into a process of its own
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md)).
|
||||
- **A malformed packet on a milter socket of the panel ends the panel.** The
|
||||
milter library indexes into an empty packet and nobody recovers the panic,
|
||||
so the process exits and supervisord starts it again. The sockets are
|
||||
reachable only inside the container, by the group Postfix is in (mode
|
||||
0660); the journal milter always had this, and since `2.4.0` the seconds
|
||||
of the restart defer mail instead of passing it unjournalled (technical
|
||||
review, 2026-10-12). Trigger to revisit: anything untrusted able to open
|
||||
a Unix socket in the container.
|
||||
- **The container's `/dev/log` takes datagrams from any user in it** (since
|
||||
`2.6.0`, for OpenDKIM's log): what is written there reaches the container
|
||||
log, each line marked `syslog: `, control characters written out, at most
|
||||
200 lines a second (the rest counted and dropped). A user who can write it
|
||||
can add lines to `docker logs` under that mark and no other; the panel
|
||||
reads only `mail.log`. logrotate's state file is under `/data/log`, the
|
||||
`postfix` user's directory: that user could stop rotation through it -
|
||||
as through the log itself, which it owns. Trigger to revisit: anything
|
||||
untrusted able to run in the container.
|
||||
- **When the hostname is itself a sending domain,** the server's mail is
|
||||
signed with that domain's key. Whoever may export the domain has the key,
|
||||
and its applications may send as `postmaster@<hostname>` by right. The
|
||||
guide says so; a hostname that is nobody's sending domain avoids it.
|
||||
- **A message's page can show another message's log lines after a queue-id
|
||||
collision.** Postfix's short queue ids repeat: the id is the microsecond of
|
||||
arrival and the queue file's inode, and inodes come round within seconds.
|
||||
The page takes the stored lines of the row's queue id dated within the
|
||||
row's own queue lifetime (about six days), so a second message has to get
|
||||
the same microsecond on the same inode in that span - about one in a
|
||||
million per reuse of the inode, and not something a sender can aim. When it
|
||||
happens, a `domain` user could read the log lines of a message that is not
|
||||
theirs. The search of the current `mail.log` that the page did before
|
||||
2.2.0 had the same exposure over a day. Trigger to revisit: a report of
|
||||
foreign lines on a page, or a reason to turn on `enable_long_queue_ids`,
|
||||
which ends it.
|
||||
- **A `domain` user may point an inbound domain's upstream at any host and
|
||||
port.** The upstream form checks that the host is a well-formed name or IP
|
||||
and the port is 1–65535; loopback, private and link-local addresses are
|
||||
@@ -316,11 +644,23 @@ deferred item from the roadmap.
|
||||
panel itself makes no request to the upstream. Trigger to revisit: `domain`
|
||||
users who are not trusted operators — the same condition as for CSRF tokens
|
||||
— at which point an allow-list of upstream networks, or restricting the
|
||||
field to the global role, is the fix.
|
||||
field to the global role, is the fix. Since `2.5.4` an upstream has one TLS
|
||||
mode for every inbound domain forwarded to it (Postfix keys the policy by
|
||||
the next hop): a `domain` user saving one that another tenant's domain
|
||||
uses with another mode is refused and told that mode - not the domain -
|
||||
so the form tells whether some domain forwards to a given host and port.
|
||||
Accepted with the rest of this item.
|
||||
- **Account's profile (username, e-mail) is saved without the password.**
|
||||
Saving the profile does not ask for the current password, as changing the
|
||||
password does. Today the e-mail is only the address the *Use my e-mail*
|
||||
button fills in, so a stolen session gains nothing it did not have. It
|
||||
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
|
||||
session can turn them to another address: the thief then learns what they
|
||||
say - which domain's DNS is wrong, which application is refused, when the
|
||||
certificate expires, sender addresses of old queued mail - and the user
|
||||
stops hearing it. Nothing in a notification gives access. A `domain` user
|
||||
can likewise point signed mail of this server at a third party's address,
|
||||
a dozen messages a day per assigned domain at most. The profile form also
|
||||
says when a username is taken, so a signed-in user can tell which
|
||||
usernames exist; signing in does not (`2.5.4`). It
|
||||
becomes a requirement to ask for the current password when changing
|
||||
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
|
||||
link to it — from then on whoever can change the address could take the
|
||||
@@ -372,6 +712,16 @@ whose browsers/devices are not vetted) — at that point cross-role request
|
||||
forgery inside the panel would need its own analysis, separate from the
|
||||
external-site case this ADR covers.
|
||||
|
||||
**Decision of 2026-10-13 (owner).** Asked to choose between this ADR, tokens,
|
||||
and a strict origin check, the owner chose the ADR with the strict check
|
||||
([origin-strict](roadmap.md#origin-strict)) and no tokens. Session-bound tokens
|
||||
are the answer to return to - a hidden field in every state-changing form and
|
||||
htmx path, checked against the session, on top of the origin check - when
|
||||
either condition above comes true, and also when: a browser the panel must
|
||||
support is found to send neither header or to send them wrongly; the panel
|
||||
gains a state-changing request that a browser sends without these headers; or
|
||||
an outside review requires a defence that the server alone holds.
|
||||
|
||||
## How this list grows
|
||||
|
||||
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
module github.com/mixeme/selfpost
|
||||
|
||||
go 1.26
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/emersion/go-milter v0.4.1
|
||||
golang.org/x/crypto v0.54.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,14 +4,14 @@ github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
|
||||
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -19,12 +19,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -32,17 +32,16 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -55,33 +54,33 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+152
-7
@@ -4,23 +4,48 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
)
|
||||
|
||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||
// for application credentials (architecture.md § Mail path). The panel is the
|
||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||
// created and removed with the standard saslpasswd2 tool.
|
||||
//
|
||||
// saslpasswd2 is never pointed at the live file. Cyrus SASL 2.1.28 opens
|
||||
// sasldb2 with no Berkeley DB environment, so without locks
|
||||
// (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL, 0)), and a
|
||||
// write puts and deletes in the file itself (_sasldb_putdata()); smtpd opens
|
||||
// and closes it for every lookup (_sasldb_getdata()), and the backup reads it
|
||||
// whenever it runs. Either could read a page half written: a spurious 535 for
|
||||
// an application, or a torn sasldb2 in a backup whose restore breaks every
|
||||
// application's AUTH (assumptions C-S3, C-Q3). So each change is made to a
|
||||
// copy, which is then renamed over the file (replaceSASLDB): a reader opens
|
||||
// the old file or the new one, whole, and needs no lock. mu keeps the
|
||||
// panel's own changes from copying the same old file at once.
|
||||
type SASLDB struct {
|
||||
path string // sasldb2 file, under /data so it survives restarts
|
||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
||||
// the default shells out to the real binary via runSaslpasswd2.
|
||||
run func(args []string, stdin []byte) error
|
||||
|
||||
// replace makes a change to the database through a copy: edit gets the
|
||||
// path to give saslpasswd2. The default is replaceSASLDB.
|
||||
replace func(path string, edit func(path string) error) error
|
||||
|
||||
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
|
||||
// a field so tests can substitute a fake; the default runs db_dump.
|
||||
dump func(path string) ([]byte, error)
|
||||
@@ -29,13 +54,101 @@ type SASLDB struct {
|
||||
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
||||
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||
// Postfix's SASL configuration.
|
||||
//
|
||||
// Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
|
||||
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
|
||||
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
|
||||
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
|
||||
// server's name (common/plugin_common.c, _plug_parseuser()).
|
||||
func NewSASLDB(path, realm string) *SASLDB {
|
||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB, replace: replaceSASLDB}
|
||||
}
|
||||
|
||||
// change runs saslpasswd2 with args on a copy of the database and puts the
|
||||
// copy in its place; at in args stands for the path of the copy.
|
||||
func (s *SASLDB) change(args []string, at int, stdin []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.replace(s.path, func(path string) error {
|
||||
a := append([]string(nil), args...)
|
||||
a[at] = path
|
||||
return s.run(a, stdin)
|
||||
})
|
||||
}
|
||||
|
||||
// replaceSASLDB copies the database at path to path+".new", lets edit change
|
||||
// the copy, and renames it over path. With no database yet, edit gets a path
|
||||
// where none exists, and saslpasswd2 creates one there (db_berkeley.c,
|
||||
// berkeleydb_open(): DB_CREATE for a writer). The copy is a byte copy of a
|
||||
// file no one else writes, which is a whole Berkeley DB file: smtpd and
|
||||
// db_dump only read it. The new file is 0640 in the setgid directory, group
|
||||
// selfpost, as build/entrypoint.sh sets the old one, so Postfix reads it.
|
||||
// A copy left by a panel killed mid-change is removed first.
|
||||
func replaceSASLDB(path string, edit func(path string) error) error {
|
||||
tmp := path + ".new"
|
||||
if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove %s: %w", tmp, err)
|
||||
}
|
||||
if err := copySASLDB(path, tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := edit(tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
// Read-write: fsync of a file opened read-only is refused on Windows.
|
||||
f, err := os.OpenFile(tmp, os.O_RDWR, 0)
|
||||
if err == nil {
|
||||
err = f.Chmod(0o640)
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("finish %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename %s -> %s: %w", tmp, path, err)
|
||||
}
|
||||
// The rename is the directory's change; without its own sync a power
|
||||
// loss may bring the old database back, out of step with the panel's
|
||||
// records (review of stage 2c, F10). A directory that cannot be synced
|
||||
// - not on every system - leaves the change as durable as before.
|
||||
_ = atomicfile.SyncDir(filepath.Dir(path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// copySASLDB copies path to tmp; with no file at path it makes no copy.
|
||||
func copySASLDB(path, tmp string) error {
|
||||
src, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", tmp, err)
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
dst.Close()
|
||||
return fmt.Errorf("copy %s: %w", path, err)
|
||||
}
|
||||
return dst.Close()
|
||||
}
|
||||
|
||||
// Set creates or updates an application's SASL account with the given password
|
||||
// (architecture.md § Mail path). Used both at creation and when a password is
|
||||
// regenerated; saslpasswd2 overwrites an existing entry in place.
|
||||
// regenerated; saslpasswd2 overwrites an existing entry (in the copy, see
|
||||
// SASLDB).
|
||||
//
|
||||
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
|
||||
// cannot leak through the process table or logs). The login is passed as a
|
||||
@@ -46,6 +159,11 @@ func (s *SASLDB) Set(login, password string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cyrus SASL 2.1.28, utils/saslpasswd.c, main(): getopt(), which ends the
|
||||
// options at "--"; -p reads the password with one read() and no prompt
|
||||
// (read_password()); -c is SASL_SET_CREATE, which lib/server.c,
|
||||
// sasl_setpass(), checks against nothing - an existing entry is written
|
||||
// over (sasldb/db_berkeley.c, _sasldb_putdata(), put with no flags).
|
||||
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||
// -c: create the account / set the password.
|
||||
// -f: operate on our sasldb2 rather than the system default path.
|
||||
@@ -54,7 +172,7 @@ func (s *SASLDB) Set(login, password string) error {
|
||||
// whitelist already forbids nothing that getopt would eat, but a login
|
||||
// starting with '-' is legal there — this keeps it an operand).
|
||||
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
|
||||
if err := s.run(args, []byte(password)); err != nil {
|
||||
if err := s.change(args, 3, []byte(password)); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
@@ -62,13 +180,18 @@ func (s *SASLDB) Set(login, password string) error {
|
||||
|
||||
// Delete removes an application's SASL account (product.md). A missing account
|
||||
// is not treated as an error, so deletion is idempotent and safe to retry.
|
||||
//
|
||||
// Cyrus SASL 2.1.28, plugins/sasldb.c, sasldb_auxprop_store(): "Deleting
|
||||
// something which is not there is not an error" - the SASL_NOUSER of
|
||||
// sasldb/db_berkeley.c, _sasldb_putdata(), becomes SASL_OK, so saslpasswd2 -d
|
||||
// on a missing login exits 0 (utils/saslpasswd.c, main()).
|
||||
func (s *SASLDB) Delete(login string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// -d: delete the account. "--" as in Set: the login is always an operand.
|
||||
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
|
||||
if err := s.run(args, nil); err != nil {
|
||||
if err := s.change(args, 2, nil); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
@@ -110,6 +233,17 @@ func (s *SASLDB) Secret(login string) (string, error) {
|
||||
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
||||
//
|
||||
// Cyrus SASL 2.1.28: sasldb/allockey.c, _sasldb_alloc_key(), builds the key
|
||||
// with no NUL at its end; plugins/sasldb.c, sasldb_auxprop_store(), stores the
|
||||
// value with strlen(); lib/server.c, sasl_setpass(), stores the plaintext
|
||||
// userPassword unless SASL_SET_NOPLAIN (saslpasswd2 -n, which Set does not
|
||||
// pass). Built against Berkeley DB (m4/sasldb.m4 auto_detect; debian/rules
|
||||
// records BDB_VERSION). Berkeley DB 5.3 Programmer's Reference, "Dump output
|
||||
// formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/
|
||||
// dumpload_format.html): header lines up to HEADER=END, then for a hash or
|
||||
// btree database key and data lines in turn, each a single space and hex,
|
||||
// and DATA=END.
|
||||
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
||||
@@ -175,19 +309,30 @@ func runSaslpasswd2(args []string, stdin []byte) error {
|
||||
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
|
||||
// is our own sasldb2 file (never user input) and is passed as a fixed argument
|
||||
// with no shell (security.md).
|
||||
//
|
||||
// Only standard output is the dump: a message db_dump writes to standard
|
||||
// error is not data and is left out of what is parsed (the same rule as
|
||||
// postconf's values in internal/postfix, retry.go; review of base-trixie).
|
||||
func dumpSASLDB(path string) ([]byte, error) {
|
||||
cmd := exec.Command("db_dump", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
var errOut bytes.Buffer
|
||||
cmd.Stderr = &errOut
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(errOut.String()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithRunner replaces the command that runs saslpasswd2 and returns the same
|
||||
// manager. It exists for tests outside this package that need an application
|
||||
// service without the real binary; the panel never calls it.
|
||||
// service without the real binary; the panel never calls it. With no real
|
||||
// database either, the runner is given the database's own path, not a copy.
|
||||
func (s *SASLDB) WithRunner(run func(args []string, stdin []byte) error) *SASLDB {
|
||||
s.run = run
|
||||
s.replace = inPlace
|
||||
return s
|
||||
}
|
||||
|
||||
// inPlace hands edit the path itself: for tests whose runner touches no file.
|
||||
func inPlace(path string, edit func(path string) error) error { return edit(path) }
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A change is made to a copy and renamed over the database: saslpasswd2
|
||||
// never gets the live file, and a reader that opened the old file keeps
|
||||
// reading it whole (assumptions C-S3).
|
||||
func TestSASLChangeGoesThroughACopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db := filepath.Join(dir, "sasldb2")
|
||||
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// smtpd in the middle of a lookup. Windows refuses a rename over an open
|
||||
// file; the container is Linux, where the reader keeps the old inode.
|
||||
var reader *os.File
|
||||
if runtime.GOOS != "windows" {
|
||||
var err error
|
||||
if reader, err = os.Open(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
}
|
||||
|
||||
s := NewSASLDB(db, "mail.example.com")
|
||||
var gotPath string
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
gotPath = args[3]
|
||||
before, err := os.ReadFile(gotPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(before) != "old database" {
|
||||
t.Errorf("the copy holds %q, want the database's bytes", before)
|
||||
}
|
||||
return os.WriteFile(gotPath, []byte("new database"), 0o600)
|
||||
}
|
||||
if err := s.Set("alerts", "pw"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if gotPath == db {
|
||||
t.Fatal("saslpasswd2 was given the live database")
|
||||
}
|
||||
if got, _ := os.ReadFile(db); string(got) != "new database" {
|
||||
t.Fatalf("database after the change: %q", got)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if fi, _ := os.Stat(db); fi.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("database mode %v, want 0640 for Postfix's group", fi.Mode().Perm())
|
||||
}
|
||||
old := make([]byte, 64)
|
||||
n, _ := reader.ReadAt(old, 0)
|
||||
if string(old[:n]) != "old database" {
|
||||
t.Fatalf("a reader of the old file read %q", old[:n])
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("the copy is left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSASLFailedChangeLeavesTheDatabase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db := filepath.Join(dir, "sasldb2")
|
||||
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A copy a killed panel left behind is not taken for the database.
|
||||
if err := os.WriteFile(db+".new", []byte("stale"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewSASLDB(db, "mail.example.com")
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
if b, _ := os.ReadFile(args[2]); string(b) != "old database" {
|
||||
t.Errorf("the copy holds %q", b)
|
||||
}
|
||||
_ = os.WriteFile(args[2], []byte("half"), 0o640)
|
||||
return errors.New("saslpasswd2: exit status 1")
|
||||
}
|
||||
if err := s.Delete("alerts"); err == nil {
|
||||
t.Fatal("Delete succeeded with saslpasswd2 failing")
|
||||
}
|
||||
if got, _ := os.ReadFile(db); string(got) != "old database" {
|
||||
t.Fatalf("database after a failed change: %q", got)
|
||||
}
|
||||
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("the copy is left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The first account: there is no database to copy, and saslpasswd2 creates
|
||||
// one at the path it is given.
|
||||
func TestSASLFirstAccountCreatesTheDatabase(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "sasldb2")
|
||||
s := NewSASLDB(db, "mail.example.com")
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
if _, err := os.Stat(args[3]); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("saslpasswd2 given an existing file %s: %v", args[3], err)
|
||||
}
|
||||
return os.WriteFile(args[3], []byte("created"), 0o660)
|
||||
}
|
||||
if err := s.Set("alerts", "pw"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if got, _ := os.ReadFile(db); string(got) != "created" {
|
||||
t.Fatalf("database: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type fakeRun struct {
|
||||
func newFakeSASL() (*SASLDB, *fakeRun) {
|
||||
fr := &fakeRun{}
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.replace = inPlace
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
fr.calls++
|
||||
fr.args = args
|
||||
@@ -66,8 +67,10 @@ func TestSASLDeleteArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
||||
// same shape `db_dump <sasldb2>` emits.
|
||||
// makeDump builds a db_dump byte-value document from key/value byte pairs. It
|
||||
// is composed by hand in the shape the Berkeley DB 5.3 "Dump output formats"
|
||||
// page gives; a real dump may have more name=value header lines (the
|
||||
// db_load keywords) before HEADER=END, which the parser skips.
|
||||
func makeDump(pairs [][2][]byte) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
||||
|
||||
+2
-19
@@ -55,6 +55,7 @@ func (s *Service) Get(id int64) (store.Application, error) {
|
||||
// the map rebuild fails, everything is rolled back so we never leave an
|
||||
// application the panel cannot fully account for.
|
||||
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
|
||||
login = foldLogin(login)
|
||||
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
@@ -126,24 +127,6 @@ func (s *Service) Secret(login string) (string, error) {
|
||||
return s.sasl.Secret(login)
|
||||
}
|
||||
|
||||
// UpdateMode switches an application's address mode / list and rebuilds the
|
||||
// sender map (product.md). The login and password are untouched. Addresses are
|
||||
// re-validated against the application's domain.
|
||||
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
|
||||
a, err := s.store.GetApplication(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RegeneratePassword issues a fresh password for an existing application (spec
|
||||
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
|
||||
// address mode and bindings are unchanged, so no map rebuild is needed. The new
|
||||
@@ -313,7 +296,7 @@ func (s *Service) CreateWithSettings(domainID int64, login string, set Settings,
|
||||
}
|
||||
if err := s.applyAccess(a.ID, set, recalc); err != nil {
|
||||
_ = s.ClearRateLimit(a.ID)
|
||||
s.rollbackCreate(a.ID, login)
|
||||
s.rollbackCreate(a.ID, a.Login) // the login as stored, folded
|
||||
_ = s.Resync()
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fa
|
||||
|
||||
rec := &saslRecorder{set: map[string]string{}}
|
||||
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
sasl.replace = inPlace
|
||||
sasl.run = func(args []string, stdin []byte) error {
|
||||
if rec.failNext {
|
||||
rec.failNext = false
|
||||
@@ -113,6 +114,40 @@ func TestServiceCreateListValidatesDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A new login is stored in lower case, and the sender map names it so: to
|
||||
// Postfix "Ops" and "ops" are one login (audit 2026-10-12, H4). A second
|
||||
// application that differs only in case is refused, created or imported,
|
||||
// whichever domain it is for.
|
||||
func TestServiceCreateFoldsTheLogin(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
a1 := addDomain(t, st, "a.example")
|
||||
b := addDomain(t, st, "b.example")
|
||||
|
||||
a, _, err := svc.Create(a1.ID, "Ops", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Login != "ops" {
|
||||
t.Fatalf("login stored as %q, want ops", a.Login)
|
||||
}
|
||||
if _, ok := rec.set["ops"]; !ok {
|
||||
t.Fatalf("SASL account written as %v, want ops", rec.set)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Login != "ops" {
|
||||
t.Fatalf("map bindings = %+v, want the login in lower case", maps.last)
|
||||
}
|
||||
|
||||
if _, _, err := svc.Create(b.ID, "OPS", store.AddressModeWildcard, nil); !errors.Is(err, store.ErrLoginExists) {
|
||||
t.Fatalf("OPS in another domain = %v, want ErrLoginExists", err)
|
||||
}
|
||||
if err := svc.ImportApplication(b.ID, "oPs", store.AddressModeWildcard, nil, "imported-password"); !errors.Is(err, store.ErrLoginExists) {
|
||||
t.Fatalf("an imported oPs = %v, want ErrLoginExists", err)
|
||||
}
|
||||
if _, ok := rec.set["OPS"]; ok {
|
||||
t.Fatal("the refused OPS got a SASL account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateDuplicateLogin(t *testing.T) {
|
||||
svc, st, _, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
@@ -212,26 +247,6 @@ func TestServiceDeleteKeepsRowWhenSASLFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateMode(t *testing.T) {
|
||||
svc, st, _, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil {
|
||||
t.Fatalf("UpdateMode: %v", err)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
|
||||
t.Errorf("map after mode switch = %+v", maps.last)
|
||||
}
|
||||
got, _ := st.GetApplication(a.ID)
|
||||
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 {
|
||||
t.Errorf("stored app after switch = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRegeneratePassword(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
@@ -24,10 +24,26 @@ func invalid(format string, args ...any) error {
|
||||
return &ValidationError{msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// foldLogin is a new application's login as it is stored: in lower case
|
||||
// (audit 2026-10-12, H4; owner: fold, do not refuse). Postfix compares the
|
||||
// SASL login with the sender map's without regard to case, so two logins
|
||||
// that differ only in case would be one sender to Postfix and two
|
||||
// applications to the panel. The store refuses such a pair as well
|
||||
// (idx_applications_login_nocase); a login imported from another instance is
|
||||
// not folded, since its SASL account and its client use it as it is.
|
||||
//
|
||||
// Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||
// the SASL login is compared with each owner by strcasecmp_utf8().
|
||||
func foldLogin(login string) string {
|
||||
return strings.ToLower(login)
|
||||
}
|
||||
|
||||
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
||||
// where '@' separates the user from the realm, so allowing it would change the
|
||||
// account's identity. Client validation is never trusted.
|
||||
// account's identity. Client validation is never trusted. (Cyrus SASL 2.1.28,
|
||||
// common/plugin_common.c, _plug_parseuser(): a login is split into user and
|
||||
// realm at its first "@", for saslpasswd2's store and for the lookup alike.)
|
||||
//
|
||||
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
||||
// command argument (never through a shell, security.md); this whitelist is what
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package atomicfile writes a file so that readers see the old file or the
|
||||
// new one, whole, and a power loss after Write returns keeps the new one.
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
@@ -7,7 +9,13 @@ import (
|
||||
)
|
||||
|
||||
// Write writes data to path via a temp file in the same directory followed by
|
||||
// a rename, so readers only ever see the complete old or new file.
|
||||
// a rename, so readers only ever see the complete old or new file. The file
|
||||
// is synced before the rename and the directory after it: the rename is a
|
||||
// change of the directory, and without its own sync a power loss may bring
|
||||
// the old file back (fsync(2): syncing a file "does not necessarily ensure
|
||||
// that the entry in the directory containing the file has also reached
|
||||
// disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
|
||||
// OpenDKIM goes through here.
|
||||
func Write(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
@@ -41,5 +49,19 @@ func Write(path string, data []byte, perm os.FileMode) error {
|
||||
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
|
||||
}
|
||||
cleanup = false
|
||||
_ = SyncDir(dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncDir makes a rename in dir durable. It is best effort for its callers:
|
||||
// the new file is in place and readable either way, and not every system
|
||||
// syncs a directory - Go on Windows cannot open one for it - so a failure
|
||||
// leaves the change as durable as it was before this existed.
|
||||
func SyncDir(dir string) error {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// leftovers lists what is in dir besides the names given.
|
||||
func leftovers(t *testing.T, dir string, names ...string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, n := range names {
|
||||
keep[n] = true
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !keep[e.Name()] {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A new file and a replaced one hold what was written, with the mode asked
|
||||
// for, and nothing is left beside them.
|
||||
func TestWriteCreatesAndReplaces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "map")
|
||||
for _, body := range []string{"first\n", "second, longer than the first\n", ""} {
|
||||
if err := Write(path, []byte(body), 0o640); err != nil {
|
||||
t.Fatalf("Write %q: %v", body, err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != body {
|
||||
t.Fatalf("file = %q, %v; want %q", got, err, body)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("mode = %v, want 0640", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if rest := leftovers(t, dir, "map"); len(rest) != 0 {
|
||||
t.Errorf("Write left %v", rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reader that opened the old file reads the old file to its end: the new
|
||||
// one takes the name, it does not overwrite the old one's bytes.
|
||||
func TestAReaderKeepsTheOldFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not rename over an open file; the container is Linux")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "map")
|
||||
if err := Write(path, []byte("old contents\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := Write(path, []byte("new\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, _ := f.Read(buf)
|
||||
if string(buf[:n]) != "old contents\n" {
|
||||
t.Errorf("the open reader read %q", buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
// A write that fails leaves the old file and no temporary one.
|
||||
func TestAFailedWriteLeavesTheOldFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := Write(filepath.Join(dir, "missing", "map"), []byte("x"), 0o640); err == nil {
|
||||
t.Error("Write into a directory that is not there succeeded")
|
||||
}
|
||||
|
||||
// The name is a directory: the rename fails after the temporary file
|
||||
// was written.
|
||||
target := filepath.Join(dir, "taken")
|
||||
if err := os.MkdirAll(filepath.Join(target, "inside"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Write(target, []byte("x"), 0o640); err == nil {
|
||||
t.Error("Write over a directory succeeded")
|
||||
}
|
||||
if rest := leftovers(t, dir, "taken"); len(rest) != 0 {
|
||||
t.Errorf("the failed Write left %v", rest)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "inside")); err != nil {
|
||||
t.Errorf("the failed Write changed the directory in its way: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The directory is synced where the system allows it; on Linux, where the
|
||||
// container runs, it does.
|
||||
func TestSyncDir(t *testing.T) {
|
||||
err := SyncDir(t.TempDir())
|
||||
if runtime.GOOS == "linux" && err != nil {
|
||||
t.Errorf("SyncDir on Linux = %v", err)
|
||||
}
|
||||
if err := SyncDir(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Error("SyncDir of a directory that is not there = nil")
|
||||
}
|
||||
}
|
||||
+257
-51
@@ -1,21 +1,22 @@
|
||||
// Package backup implements SelfPost's full-server backup and the restore
|
||||
// version guard (architecture.md § Persistence). A full backup is a
|
||||
// gzip-compressed tar of the consolidated persistent state under /data — the
|
||||
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
|
||||
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
|
||||
// certs/ from the operator's deploy directory, and a manifest recording the
|
||||
// SelfPost version that produced it. Postfix delivery logs under log/ are
|
||||
// excluded (diagnostics, not state).
|
||||
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
|
||||
// SASL database — plus docker-compose.yml, .env, and certs/ from the
|
||||
// operator's deploy directory, and a manifest recording the SelfPost version
|
||||
// that produced it. Postfix delivery logs under log/ are excluded
|
||||
// (diagnostics, not state), and so is the Postfix queue (see QueueDir).
|
||||
//
|
||||
// Restore is not a separate code path in the panel: a backup is extracted into
|
||||
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
|
||||
// before first start. The archive carries everything needed to bring the
|
||||
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
|
||||
// instance back — DKIM keys, sasldb2, sender map, and deploy files —
|
||||
// so the operator only adjusts hostname or proxy settings on a new host. The
|
||||
// restore-specific steps the panel runs are CheckRestore, which refuses to
|
||||
// boot if the manifest's version does not match the running binary so
|
||||
// schema/format skew between versions cannot silently corrupt state
|
||||
// (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
|
||||
// (architecture.md § Persistence), or if the archive was unpacked in part
|
||||
// (ContentsName), and a one-time Resync of OpenDKIM's tables
|
||||
// and the Postfix sender map from SQLite on that first boot, so any drift
|
||||
// between the archive and the database is healed before mail flows. If
|
||||
// on-disk state drifts again later — for example after a manual edit under
|
||||
@@ -25,9 +26,11 @@ package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -81,6 +84,20 @@ type Params struct {
|
||||
OnWarn func(string)
|
||||
}
|
||||
|
||||
// QueueDir is the Postfix queue, relative to the data directory. No backup
|
||||
// holds it (owner, 2026-10-11). An archive is made while the instance runs,
|
||||
// and Postfix moves messages between the queue's directories all the time: a
|
||||
// copy taken then can hold a message twice or not at all, so what it would
|
||||
// bring back is not the queue. Nor could the panel read it - Postfix keeps
|
||||
// every queue file readable by its own user alone, which is why the panel's
|
||||
// backup failed outright from 1.3.0 to 2.0.0 (date not recorded; Postfix
|
||||
// 3.10.13, conf/postfix-files: active, deferred, incoming and the other queue
|
||||
// directories are $mail_owner 700). What is not carried over is mail
|
||||
// still waiting for delivery; a start without a queue directory builds an
|
||||
// empty one. Whoever needs the queue stops the instance and archives the
|
||||
// project directory (guide § Taking a full backup).
|
||||
const QueueDir = "postfix/queue"
|
||||
|
||||
// excludedFromArchive lists the data-directory entries a backup never carries.
|
||||
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
||||
// written under the canonical name; the setup token is transient bootstrap
|
||||
@@ -98,12 +115,15 @@ var excludedFromArchive = map[string]bool{
|
||||
"tls": true,
|
||||
"log": true,
|
||||
ManifestName: true,
|
||||
ContentsName: true,
|
||||
contentsCheckedName: true,
|
||||
}
|
||||
|
||||
// Create writes a gzip-compressed tar backup to w. Archive layout:
|
||||
//
|
||||
// data/manifest.json, data/selfpost.db, data/<rest of /data>
|
||||
// docker-compose.yml, .env, certs/...
|
||||
// data/contents.json
|
||||
//
|
||||
// Extract the archive into an empty project directory, then docker compose up.
|
||||
func Create(w io.Writer, p Params) error {
|
||||
@@ -125,6 +145,7 @@ func Create(w io.Writer, p Params) error {
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
a := &archive{tw: tw}
|
||||
|
||||
manifest := Manifest{
|
||||
Format: FormatFull,
|
||||
@@ -135,21 +156,26 @@ func Create(w io.Writer, p Params) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: encode manifest: %w", err)
|
||||
}
|
||||
if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
|
||||
if err := writeTarBytes(a, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The consistent SQLite snapshot, under the canonical filename the panel
|
||||
// opens on start (the live file and its WAL/SHM are excluded from the walk).
|
||||
if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
|
||||
if err := writeTarFile(a, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
|
||||
if _, err := addTree(a, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, p.OnWarn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
|
||||
if err := addDeployFiles(a, p.DeployRoot, p.OnWarn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Last, so that only an archive unpacked to its end has it.
|
||||
if err := a.writeContents(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -181,7 +207,60 @@ func validateDeployRoot(deployRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
|
||||
// skipFunc says whether the entry at path (rel to the root being walked) is
|
||||
// left out of the archive. A directory left out is not descended into.
|
||||
type skipFunc func(path, rel string, d fs.DirEntry) bool
|
||||
|
||||
// skipQueue leaves the Postfix queue out of every archive, and the copy of
|
||||
// sasldb2 a change is made on (internal/app, replaceSASLDB): a panel killed
|
||||
// mid-change leaves it, a second copy of every application's password, until
|
||||
// the next start removes it (build/entrypoint.sh; review of stage 2c).
|
||||
func skipQueue(_, rel string, _ fs.DirEntry) bool {
|
||||
switch filepath.ToSlash(rel) {
|
||||
case QueueDir, "sasl/sasldb2.new":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check reads through everything Create would put into the archive, without
|
||||
// writing one, and returns the first thing that cannot be read. The panel
|
||||
// streams its archive to the browser, so once Create has begun a failure can
|
||||
// only cut the download short; this is run first, while a refusal can still be
|
||||
// shown as a page.
|
||||
//
|
||||
// The panel is not root, and nothing is left out on that account: its archive
|
||||
// is the one selfpost-backup makes, or it is not made. What it most likely
|
||||
// cannot read is the private key under certs/ - see UnreadableCert.
|
||||
func Check(p Params) error {
|
||||
if p.DataDir == "" || p.DBPath == "" {
|
||||
return fmt.Errorf("backup: DataDir and DBPath are required")
|
||||
}
|
||||
if err := ValidateDeployRoot(p.DeployRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return addDeployFiles(nil, p.DeployRoot, nil)
|
||||
}
|
||||
|
||||
// UnreadableCert reports whether err, from Check or Create, is a file under
|
||||
// the deploy root's certs/ that the caller is not allowed to read, and names
|
||||
// it relative to the deploy root ("certs/privkey.pem").
|
||||
func UnreadableCert(err error, deployRoot string) (name string, ok bool) {
|
||||
var pathErr *fs.PathError
|
||||
if !errors.As(err, &pathErr) || !errors.Is(err, fs.ErrPermission) {
|
||||
return "", false
|
||||
}
|
||||
rel, relErr := filepath.Rel(filepath.Join(deployRoot, CertsDirName), pathErr.Path)
|
||||
if relErr != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
||||
return "", false
|
||||
}
|
||||
return CertsDirName + "/" + filepath.ToSlash(rel), true
|
||||
}
|
||||
|
||||
func addDeployFiles(a *archive, deployRoot string, onWarn func(string)) error {
|
||||
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||
src := filepath.Join(deployRoot, name)
|
||||
info, err := os.Stat(src)
|
||||
@@ -191,12 +270,19 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
|
||||
}
|
||||
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
|
||||
if err := writeTarFile(a, name, info.Mode().Perm(), src); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
certsDir := filepath.Join(deployRoot, CertsDirName)
|
||||
// certs/ itself may be a link - the guide once had it linked to
|
||||
// certbot's live/<host> - and the walk below does not follow its root
|
||||
// (path/filepath, WalkDir: "WalkDir does not follow symbolic links"), so
|
||||
// the link is resolved first (review of stage 2c, F7).
|
||||
if resolved, err := filepath.EvalSymlinks(certsDir); err == nil {
|
||||
certsDir = resolved
|
||||
}
|
||||
if _, err := os.Stat(certsDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if onWarn != nil {
|
||||
@@ -206,15 +292,48 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
|
||||
}
|
||||
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
|
||||
}
|
||||
return addTree(tw, certsDir, CertsDirName+"/", nil)
|
||||
// certs/ may hold links rather than files: certbot's own advice is to
|
||||
// point a server at its live/ files "(or create symlinks)", and those
|
||||
// are links again, relative, into archive/ (eff-certbot docs, "Where are
|
||||
// my certificates?"; assumptions C-Q4, C-A1). The archive carries the
|
||||
// files they lead to, under the links' names.
|
||||
files, err := addTree(a, certsDir, CertsDirName+"/", nil, nil, true, onWarn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// certs/ is what Postfix serves its certificate from (the compose file
|
||||
// mounts it at /etc/postfix/tls). There and empty - or holding only what
|
||||
// the archive cannot carry - it would give an archive that restores a
|
||||
// server without its certificate, so there is none (audit 2026-10-12,
|
||||
// L2).
|
||||
if files == 0 {
|
||||
return ErrNoCerts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNoCerts is a certs/ that is there with no file a backup can carry.
|
||||
var ErrNoCerts = errors.New("backup: " + CertsDirName + "/ holds no file this backup can carry, so the server it restores would have no certificate - put the PEM files (or links to them) there")
|
||||
|
||||
// addTree walks root and adds every regular file (and directory, to preserve
|
||||
// empty ones and modes) to tw under archivePrefix + path relative to root.
|
||||
// When exclude is non-nil, top-level names relative to root are skipped.
|
||||
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
|
||||
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
|
||||
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
// empty ones and modes) to a under archivePrefix + path relative to root, and
|
||||
// returns how many files it took.
|
||||
// When exclude is non-nil, top-level names relative to root are skipped; skip,
|
||||
// when non-nil, leaves out whatever it says. With a nil a nothing is written:
|
||||
// every file is opened and closed, which is how Check finds what cannot be read.
|
||||
// Non-regular, non-directory entries (symlinks, sockets) are skipped - except,
|
||||
// with follow, a symbolic link to a regular file, which is archived as that
|
||||
// file under the link's name; a link that leads nowhere this process can
|
||||
// reach is an error, as an unreadable file is. Each entry left out that way
|
||||
// is told to warn, when it is not nil: a backup does not leave something out
|
||||
// without saying so (audit 2026-10-12, L2).
|
||||
func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool, warn func(string)) (files int, err error) {
|
||||
skipped := func(name, what string) {
|
||||
if warn != nil {
|
||||
warn(name + " is " + what + ", not a file: the backup does not carry it")
|
||||
}
|
||||
}
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -236,78 +355,146 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool
|
||||
}
|
||||
}
|
||||
|
||||
if skip != nil && skip(path, rel, d) {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case d.IsDir():
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
hdr := &tar.Header{
|
||||
Typeflag: tar.TypeDir,
|
||||
Name: name + "/",
|
||||
Mode: int64(info.Mode().Perm()),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
return tw.WriteHeader(hdr)
|
||||
return a.tw.WriteHeader(hdr)
|
||||
case info.Mode().IsRegular():
|
||||
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||
err := writeTarFile(a, name, info.Mode().Perm(), path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// A writer's temporary file, renamed into place between
|
||||
// the listing and the open: not part of /data's state.
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
files++
|
||||
}
|
||||
return err
|
||||
case follow && info.Mode()&fs.ModeSymlink != 0:
|
||||
target, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: %s is a link to a file this container cannot reach - with certbot, /etc/letsencrypt is mounted at the same path (guide § Reverse proxy): %w", name, err)
|
||||
}
|
||||
if !target.Mode().IsRegular() {
|
||||
skipped(name, "a link to a directory")
|
||||
return nil
|
||||
}
|
||||
if err := writeTarFile(a, name, target.Mode().Perm(), path); err != nil {
|
||||
return err
|
||||
}
|
||||
files++
|
||||
return nil
|
||||
case info.Mode()&fs.ModeSymlink != 0:
|
||||
skipped(name, "a link")
|
||||
return nil
|
||||
default:
|
||||
skipped(name, kindOf(info.Mode()))
|
||||
return nil
|
||||
}
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// kindOf names what a non-regular, non-directory entry is, for a warning.
|
||||
func kindOf(m fs.FileMode) string {
|
||||
switch {
|
||||
case m&fs.ModeSocket != 0:
|
||||
return "a socket"
|
||||
case m&fs.ModeNamedPipe != 0:
|
||||
return "a named pipe"
|
||||
case m&fs.ModeDevice != 0:
|
||||
return "a device"
|
||||
default:
|
||||
return "neither a file nor a directory"
|
||||
}
|
||||
}
|
||||
|
||||
// writeTarBytes writes an in-memory file entry.
|
||||
func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
|
||||
hdr := &tar.Header{
|
||||
func writeTarBytes(a *archive, name string, mode int64, data []byte) error {
|
||||
return a.writeFile(&tar.Header{
|
||||
Typeflag: tar.TypeReg,
|
||||
Name: name,
|
||||
Mode: mode,
|
||||
Size: int64(len(data)),
|
||||
ModTime: time.Now().UTC(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("backup: write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// writeTarFile streams a file from disk into the archive under name.
|
||||
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
|
||||
//
|
||||
// The file is read through one descriptor, sized by that descriptor: the
|
||||
// panel's writers of what a backup carries put a new file in place by rename
|
||||
// (atomicfile.Write; sasldb2 through a copy, app.replaceSASLDB - the setup
|
||||
// token alone is written in place, and it is not carried; the database and
|
||||
// the queue are not walked), and a rename leaves the file open here as it
|
||||
// was: the archive holds the old file or the new one, whole (assumptions
|
||||
// C-Q3).
|
||||
func writeTarFile(a *archive, name string, mode fs.FileMode, srcPath string) error {
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: open %s: %w", srcPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: stat %s: %w", srcPath, err)
|
||||
}
|
||||
hdr := &tar.Header{
|
||||
return a.writeFile(&tar.Header{
|
||||
Typeflag: tar.TypeReg,
|
||||
Name: name,
|
||||
Mode: int64(mode.Perm()),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return fmt.Errorf("backup: copy %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}, f)
|
||||
}
|
||||
|
||||
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
|
||||
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
|
||||
// the panel is writing to the live file under WAL. It returns the snapshot path
|
||||
// and a cleanup function the caller must defer.
|
||||
// and a cleanup function the caller must defer. (sqlite.org/lang_vacuum.html,
|
||||
// "VACUUM INTO": "transactional in the sense that the generated output
|
||||
// database is a consistent snapshot"; modernc.org/sqlite v1.60.1, sqlite.go,
|
||||
// applyQueryParams(): each _pragma is run as "pragma <value>" on connect,
|
||||
// busy_timeout first.)
|
||||
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
// The database must be there: SQLite creates a file that is not, and an
|
||||
// archive of that empty database restores a panel that offers its
|
||||
// first-run setup on a server holding live keys (audit 2026-10-12, M3).
|
||||
// mode=ro makes the open fail rather than create one, should the file go
|
||||
// between the two: SQLite 3.53.4 creates a database only under "rwc", the
|
||||
// default (https://sqlite.org/uri.html § 3.3), and VACUUM INTO is not a
|
||||
// write to the source (lang_vacuum.html § 2.1, § 3) - both measured by
|
||||
// TestSnapshotRefusesAMissingDatabase.
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("backup: the database: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("backup: the database %s is not a file", dbPath)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
|
||||
@@ -317,7 +504,7 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
|
||||
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
|
||||
// failing immediately if the panel happens to be mid-write.
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("backup: open database: %w", err)
|
||||
@@ -338,10 +525,13 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
// CheckRestore enforces the backup version guard (architecture.md §
|
||||
// Persistence). If manifestPath exists (a backup was extracted into the data
|
||||
// directory), its version must match binaryVersion or the panel refuses to
|
||||
// start, telling the operator which image tag to use. On a match the manifest
|
||||
// is consumed (deleted) so it guards only the first boot after a restore and
|
||||
// never blocks a later in-place image upgrade, and restored is true so the
|
||||
// caller can heal drifted daemon maps once. Absence of the manifest is the
|
||||
// start, telling the operator which image tag to use; and the data directory
|
||||
// must hold every file the archive recorded at its end, as the archive holds
|
||||
// it, or the panel refuses to start on an archive unpacked in part (see
|
||||
// ContentsName). Then restored is true so the caller can heal drifted daemon
|
||||
// maps once. The manifest stays until the caller has done that and calls
|
||||
// ConsumeRestore: a start that fails on the way leaves it, and the next start
|
||||
// is a restore again (audit 2026-10-12, M1). Absence of the manifest is the
|
||||
// normal case and returns restored == false with a nil error.
|
||||
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
@@ -364,10 +554,26 @@ func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error)
|
||||
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
|
||||
m.Version, binaryVersion, m.Version)
|
||||
}
|
||||
// Version matches: consume the manifest so subsequent normal starts (and
|
||||
// in-place upgrades) are not gated by it.
|
||||
if err := os.Remove(manifestPath); err != nil {
|
||||
return false, fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
if err := checkContents(filepath.Dir(manifestPath)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ConsumeRestore ends a restore once its first start has done what a restore
|
||||
// start does: the manifest is removed, so later starts (and in-place
|
||||
// upgrades) are not gated by it, and then the record of the files, checked or
|
||||
// unpacked again since. A start stopped between the two leaves the record
|
||||
// alone, which nothing reads without a manifest and no backup carries.
|
||||
func ConsumeRestore(manifestPath string) error {
|
||||
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
}
|
||||
for _, name := range []string{contentsCheckedName, ContentsName} {
|
||||
err := os.Remove(filepath.Join(filepath.Dir(manifestPath), name))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@ import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -40,13 +46,15 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
|
||||
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
||||
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||
writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n")
|
||||
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
|
||||
// Transient files that must NOT be archived.
|
||||
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
|
||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
|
||||
// Postfix's delivery log and its rotated files: diagnostic output, not
|
||||
// state, and the bulkiest thing under /data.
|
||||
// state, and the bulkiest thing under /data. The line is composed by hand;
|
||||
// only its absence from the archive matters.
|
||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
|
||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
||||
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||
@@ -112,7 +120,10 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "sasl/sasldb2",
|
||||
DataArchivePrefix + "postfix/sender_login_maps",
|
||||
DataArchivePrefix + "postfix/queue/deferred/sample",
|
||||
// The filter lists as the spam filter reads them. They are rewritten
|
||||
// from the database on the first start after a restore; carrying them
|
||||
// means the filter has its lists before the panel is up.
|
||||
DataArchivePrefix + "antispam/deny_domain.map",
|
||||
ComposeFileName,
|
||||
EnvFileName,
|
||||
CertsDirName + "/fullchain.pem",
|
||||
@@ -129,6 +140,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||
DataArchivePrefix + "selfpost.db-shm",
|
||||
DataArchivePrefix + "log/mail.log",
|
||||
DataArchivePrefix + "log/mail.log.1",
|
||||
DataArchivePrefix + "postfix/queue/deferred/sample",
|
||||
} {
|
||||
if _, ok := files[name]; ok {
|
||||
t.Errorf("archive should not contain %s", name)
|
||||
@@ -191,9 +203,33 @@ func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
||||
// unpackData writes the data directory of a Create archive into a new
|
||||
// directory and returns the manifest's path there.
|
||||
func unpackData(t *testing.T, archive []byte) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||
for name, body := range readArchive(t, archive) {
|
||||
if rel, ok := strings.CutPrefix(name, DataArchivePrefix); ok {
|
||||
writeFile(t, filepath.Join(dir, filepath.FromSlash(rel)), body)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, ManifestName)
|
||||
}
|
||||
|
||||
func createArchive(t *testing.T, version string) []byte {
|
||||
t.Helper()
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
var buf bytes.Buffer
|
||||
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: version, DeployRoot: deployRoot}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// A matching restore keeps its manifest until the start that runs it is done
|
||||
// with it (audit 2026-10-12, M1): CheckRestore reports, ConsumeRestore ends.
|
||||
func TestCheckRestoreMatchKeepsTheManifestUntilConsumed(t *testing.T) {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
restored, err := CheckRestore(path, "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckRestore matching = %v, want nil", err)
|
||||
@@ -201,8 +237,145 @@ func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
||||
if !restored {
|
||||
t.Fatal("CheckRestore matching did not report a restore")
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("CheckRestore consumed the manifest before the start was done with it: %v", err)
|
||||
}
|
||||
if err := ConsumeRestore(path); err != nil {
|
||||
t.Fatalf("ConsumeRestore: %v", err)
|
||||
}
|
||||
for _, name := range []string{ManifestName, ContentsName, contentsCheckedName} {
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(path), name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s left after ConsumeRestore (stat err = %v)", name, err)
|
||||
}
|
||||
}
|
||||
if err := ConsumeRestore(path); err != nil {
|
||||
t.Errorf("ConsumeRestore on a consumed restore = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The archive's last entry records the data directory's files - every one,
|
||||
// with its size and digest - and nothing of the deploy files, which the
|
||||
// operator edits before the first start.
|
||||
func TestCreateEndsWithTheRecordOfItsData(t *testing.T) {
|
||||
archive := createArchive(t, "1.0.0")
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var last string
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar next: %v", err)
|
||||
}
|
||||
last = hdr.Name
|
||||
}
|
||||
if last != DataArchivePrefix+ContentsName {
|
||||
t.Fatalf("the last entry is %s, want %s", last, DataArchivePrefix+ContentsName)
|
||||
}
|
||||
|
||||
files := readArchive(t, archive)
|
||||
var c Contents
|
||||
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ContentsName]), &c); err != nil {
|
||||
t.Fatalf("contents: %v", err)
|
||||
}
|
||||
recorded := map[string]FileSum{}
|
||||
for _, f := range c.Files {
|
||||
recorded[f.Name] = f
|
||||
}
|
||||
for name, body := range files {
|
||||
if name == DataArchivePrefix+ContentsName {
|
||||
continue
|
||||
}
|
||||
f, ok := recorded[name]
|
||||
if !strings.HasPrefix(name, DataArchivePrefix) {
|
||||
if ok {
|
||||
t.Errorf("the record names the deploy file %s", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
if !ok || f.Size != int64(len(body)) || f.SHA256 != hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("%s recorded as %+v, want size %d sha256 %x", name, f, len(body), sum)
|
||||
}
|
||||
delete(recorded, name)
|
||||
}
|
||||
for name := range recorded {
|
||||
t.Errorf("the record names %s, which the archive does not hold", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the archive's last entry, or with a file that is not the one it
|
||||
// holds, a restore is refused, and the manifest stays for the next try.
|
||||
func TestCheckRestoreRefusesAnArchiveUnpackedInPart(t *testing.T) {
|
||||
sasldb := func(body string) func(string) {
|
||||
return func(dir string) { writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), body) }
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
break_ func(dir string)
|
||||
want string
|
||||
}{
|
||||
{"no record", func(dir string) { os.Remove(filepath.Join(dir, ContentsName)) }, "contents.json, its last entry, is not there"},
|
||||
{"a record cut", func(dir string) { writeFile(t, filepath.Join(dir, ContentsName), `{"format":"selfpost-backup-con`) }, "is not a record"},
|
||||
{"a file missing", func(dir string) { os.Remove(filepath.Join(dir, "sasl", "sasldb2")) }, "data/sasl/sasldb2 is missing"},
|
||||
{"a file cut", sasldb("SASL"), "data/sasl/sasldb2 is not the file"},
|
||||
{"a file altered", sasldb("SASLDC"), "data/sasl/sasldb2 is not the file"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
c.break_(filepath.Dir(path))
|
||||
restored, err := CheckRestore(path, "1.0.0")
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "not unpacked whole") {
|
||||
t.Fatalf("CheckRestore = %v, want a refusal saying %q", err, c.want)
|
||||
}
|
||||
if restored {
|
||||
t.Error("a refused restore reported a restore")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("the refusal removed the manifest: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A record names files of the data directory only; one that names another
|
||||
// path is refused, not followed.
|
||||
func TestCheckRestoreRefusesARecordOutsideTheDataDirectory(t *testing.T) {
|
||||
for _, name := range []string{"data/../.env", "data/", "docker-compose.yml", "data//sasl/sasldb2", "data/./x", "data/.."} {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
body, _ := json.Marshal(Contents{Format: FormatContents, Files: []FileSum{{Name: name}}})
|
||||
writeFile(t, filepath.Join(filepath.Dir(path), ContentsName), string(body))
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "not a file of the data directory") {
|
||||
t.Errorf("a record naming %q = %v, want it refused", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The files are compared once per restore: a start that failed after the
|
||||
// check had the database open, and the next start must not take the changes
|
||||
// it made for damage. A different archive unpacked over the directory since
|
||||
// is compared anew.
|
||||
func TestCheckRestoreComparesTheFilesOncePerRestore(t *testing.T) {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
dir := filepath.Dir(path)
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatalf("first CheckRestore: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "selfpost.db"), "changed by the start that failed")
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatalf("the start after a failed one compared the files again: %v", err)
|
||||
}
|
||||
|
||||
// Another archive's manifest, its record lost on the way.
|
||||
b, _ := json.Marshal(Manifest{Format: FormatFull, Version: "1.0.0", CreatedAt: "another time"})
|
||||
writeFile(t, path, string(b))
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
|
||||
t.Fatalf("another archive's manifest over a checked restore = %v, want it compared anew and refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,3 +408,280 @@ func TestCheckRestoreWrongFormatRejected(t *testing.T) {
|
||||
t.Error("CheckRestore wrong format reported a restore")
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest that is not JSON - cut short, or not one at all - stops the
|
||||
// start and stays for the operator to look at.
|
||||
func TestCheckRestoreRefusesAManifestThatIsNotJSON(t *testing.T) {
|
||||
for _, body := range []string{`{"format": "selfpost-full", "version":`, "not json", ""} {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ManifestName)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := CheckRestore(path, "1.0.0")
|
||||
if err == nil || !strings.Contains(err.Error(), "not valid JSON") {
|
||||
t.Errorf("manifest %q: err = %v, want not valid JSON", body, err)
|
||||
}
|
||||
if restored {
|
||||
t.Errorf("manifest %q reported a restore", body)
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Errorf("manifest %q must be kept: %v", body, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No archive holds the Postfix queue; the rest is there, the private key
|
||||
// included, and Check agrees that it can be made.
|
||||
func TestCreateLeavesTheQueueOut(t *testing.T) {
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}
|
||||
|
||||
if err := Check(p); err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Create(&buf, p); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
files := readArchive(t, buf.Bytes())
|
||||
for name := range files {
|
||||
if strings.HasPrefix(name, DataArchivePrefix+QueueDir) {
|
||||
t.Errorf("the panel's archive holds %s", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
DataArchivePrefix + "selfpost.db",
|
||||
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "sasl/sasldb2",
|
||||
DataArchivePrefix + "postfix/sender_login_maps",
|
||||
ComposeFileName, EnvFileName,
|
||||
CertsDirName + "/fullchain.pem",
|
||||
CertsDirName + "/privkey.pem",
|
||||
} {
|
||||
if _, ok := files[name]; !ok {
|
||||
t.Errorf("archive missing %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file under certs/ that may not be read is told apart from any other
|
||||
// failure, so the panel can say what to do about it.
|
||||
func TestUnreadableCert(t *testing.T) {
|
||||
root := filepath.Join("srv", "deploy")
|
||||
denied := func(path string) error {
|
||||
return fmt.Errorf("backup: open %s: %w", path, &fs.PathError{Op: "open", Path: path, Err: fs.ErrPermission})
|
||||
}
|
||||
if name, ok := UnreadableCert(denied(filepath.Join(root, "certs", "privkey.pem")), root); !ok || name != "certs/privkey.pem" {
|
||||
t.Errorf("certs/privkey.pem: got %q, %v", name, ok)
|
||||
}
|
||||
if name, ok := UnreadableCert(denied(filepath.Join(root, ".env")), root); ok {
|
||||
t.Errorf(".env was taken for a certificate file: %q", name)
|
||||
}
|
||||
if name, ok := UnreadableCert(denied(filepath.Join("data", "postfix", "x")), root); ok {
|
||||
t.Errorf("a data file was taken for a certificate file: %q", name)
|
||||
}
|
||||
missing := &fs.PathError{Op: "open", Path: filepath.Join(root, "certs", "privkey.pem"), Err: fs.ErrNotExist}
|
||||
if _, ok := UnreadableCert(missing, root); ok {
|
||||
t.Error("a missing file was taken for one that may not be read")
|
||||
}
|
||||
}
|
||||
|
||||
// Check finds what Create would stumble over, before anything is written.
|
||||
func TestCheckReportsWhatCannotBeRead(t *testing.T) {
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
if err := os.Remove(filepath.Join(deployRoot, EnvFileName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Check(Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err == nil {
|
||||
t.Fatal("Check passed with .env missing from the deploy root")
|
||||
}
|
||||
}
|
||||
|
||||
// certs/ as certbot's advice leaves it: links into live/, which are relative
|
||||
// links into archive/ (C-Q4). The archive holds the files, under the links'
|
||||
// names; a link to nothing reachable stops the backup, naming it.
|
||||
func TestCertsLinksAreFollowed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symbolic links need privileges on Windows; the container is Linux")
|
||||
}
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
le := t.TempDir()
|
||||
writeFile(t, filepath.Join(le, "archive", "mail.example.com", "fullchain1.pem"), "CERT FROM CERTBOT")
|
||||
writeFile(t, filepath.Join(le, "archive", "mail.example.com", "privkey1.pem"), "KEY FROM CERTBOT")
|
||||
live := filepath.Join(le, "live", "mail.example.com")
|
||||
if err := os.MkdirAll(live, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certs := filepath.Join(deployRoot, CertsDirName)
|
||||
for _, f := range []string{"fullchain", "privkey"} {
|
||||
if err := os.Symlink("../../archive/mail.example.com/"+f+"1.pem", filepath.Join(live, f+".pem")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Remove(filepath.Join(certs, f+".pem"))
|
||||
if err := os.Symlink(filepath.Join(live, f+".pem"), filepath.Join(certs, f+".pem")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}
|
||||
if err := Check(p); err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Create(&buf, p); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
files := readArchive(t, buf.Bytes())
|
||||
if files[CertsDirName+"/fullchain.pem"] != "CERT FROM CERTBOT" || files[CertsDirName+"/privkey.pem"] != "KEY FROM CERTBOT" {
|
||||
t.Fatalf("certs in the archive: %q, %q", files[CertsDirName+"/fullchain.pem"], files[CertsDirName+"/privkey.pem"])
|
||||
}
|
||||
|
||||
// /etc/letsencrypt not mounted: the link leads nowhere.
|
||||
if err := os.RemoveAll(le); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := Check(p)
|
||||
if err == nil || !strings.Contains(err.Error(), "certs/fullchain.pem is a link to a file this container cannot reach") {
|
||||
t.Fatalf("Check with a dangling link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong SELFPOST_DB_PATH is refused, and nothing is created where it
|
||||
// points: SQLite makes a database of a path that is not one, and an archive
|
||||
// of that empty database restores a server that offers its first-run setup
|
||||
// (audit 2026-10-12, M3). The snapshot opens the source read-only, which is
|
||||
// what refuses it should the file go between the check and the open.
|
||||
func TestSnapshotRefusesAMissingDatabase(t *testing.T) {
|
||||
dataDir, _, deployRoot := seedDataDir(t)
|
||||
missing := filepath.Join(dataDir, "elsewhere.db")
|
||||
var buf bytes.Buffer
|
||||
err := Create(&buf, Params{DataDir: dataDir, DBPath: missing, Version: "1.0.0", DeployRoot: deployRoot})
|
||||
if err == nil {
|
||||
t.Fatal("a backup of a database that is not there was made")
|
||||
}
|
||||
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("the refused backup created %s (stat err = %v)", missing, statErr)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+missing+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err == nil {
|
||||
t.Fatal("mode=ro opened a database that is not there")
|
||||
}
|
||||
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("mode=ro created %s (stat err = %v)", missing, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// certs/ there and empty is refused by Check and Create alike: the archive
|
||||
// would restore a server without its certificate (audit 2026-10-12, L2).
|
||||
func TestEmptyCertsIsRefused(t *testing.T) {
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
for _, f := range []string{"fullchain.pem", "privkey.pem"} {
|
||||
os.Remove(filepath.Join(deployRoot, CertsDirName, f))
|
||||
}
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot}
|
||||
if err := Check(p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
|
||||
t.Errorf("Check with an empty certs/ = %v", err)
|
||||
}
|
||||
if err := Create(io.Discard, p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
|
||||
t.Errorf("Create with an empty certs/ = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What a backup leaves out it says it leaves out: a link under data/, and a
|
||||
// link under certs/ to a directory (audit 2026-10-12, L2).
|
||||
func TestBackupWarnsOfWhatItLeavesOut(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symbolic links need privileges on Windows; the container is Linux")
|
||||
}
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
if err := os.Symlink("sasl/sasldb2", filepath.Join(dataDir, "a-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(deployRoot, CertsDirName, "a-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var warnings []string
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot,
|
||||
OnWarn: func(s string) { warnings = append(warnings, s) }}
|
||||
if err := Create(io.Discard, p); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got := strings.Join(warnings, "\n")
|
||||
for _, want := range []string{
|
||||
"data/a-link is a link, not a file: the backup does not carry it",
|
||||
"certs/a-directory is a link to a directory, not a file: the backup does not carry it",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no warning %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A link or a pipe where the record names a file is refused, not followed or
|
||||
// read: a crafted archive must not make the start read outside /data, or
|
||||
// wait (security review of audit stage 3).
|
||||
func TestCheckRestoreReadsOnlyRegularFiles(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symbolic links need privileges on Windows; the container is Linux")
|
||||
}
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
dir := filepath.Dir(path)
|
||||
sasl := filepath.Join(dir, "sasl", "sasldb2")
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
writeFile(t, outside, "SASLDB")
|
||||
os.Remove(sasl)
|
||||
if err := os.Symlink(outside, sasl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
|
||||
t.Fatalf("a link to a file of the same contents = %v, want refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A journal file beside the restored database is from what the directory
|
||||
// held before; SQLite would take it up, so the restore is refused.
|
||||
func TestCheckRestoreRefusesAJournalBesideTheDatabase(t *testing.T) {
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
writeFile(t, filepath.Join(filepath.Dir(path), "selfpost.db"+side), "old")
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "selfpost.db"+side+" is beside the database") {
|
||||
t.Errorf("%s beside the database = %v, want refused", side, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The archive unpacked again after a start that checked it is checked again:
|
||||
// its record wins over the mark of the earlier check, and a file it cut short
|
||||
// is refused. A finished restore leaves neither record.
|
||||
func TestARecordUnpackedAgainIsChecked(t *testing.T) {
|
||||
archive := createArchive(t, "1.0.0")
|
||||
path := unpackData(t, archive)
|
||||
dir := filepath.Dir(path)
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := readArchive(t, archive)
|
||||
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
|
||||
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), "SAS")
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
|
||||
t.Fatalf("a second unpack cut short = %v, want refused", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), files[DataArchivePrefix+"sasl/sasldb2"])
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatalf("the second unpack whole: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
|
||||
if err := ConsumeRestore(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{ManifestName, ContentsName, contentsCheckedName} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s left after ConsumeRestore", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
)
|
||||
|
||||
// FormatContents identifies the record of what an archive's data directory
|
||||
// holds.
|
||||
const FormatContents = "selfpost-backup-contents"
|
||||
|
||||
// ContentsName is the record of the data directory's files, the last entry of
|
||||
// every archive (data/contents.json). The manifest is the first entry and
|
||||
// says a restore is under way; this one says the archive was unpacked to its
|
||||
// end. A download cut short, or a tar that stopped at a damaged block, leaves
|
||||
// a data directory with the manifest and without this file, and the panel
|
||||
// refuses to start on it instead of opening half a database (audit
|
||||
// 2026-10-12, stage 3).
|
||||
const ContentsName = "contents.json"
|
||||
|
||||
// contentsCheckedName is ContentsName once a start has checked the files
|
||||
// against it. The check is made once: the start that made it opens the
|
||||
// database, and from then on the files are the panel's to change, so a start
|
||||
// after a failed one (manifest still there) must not compare them again.
|
||||
const contentsCheckedName = "contents.checked.json"
|
||||
|
||||
// Contents is the record written as ContentsName.
|
||||
type Contents struct {
|
||||
Format string `json:"format"`
|
||||
Files []FileSum `json:"files"`
|
||||
}
|
||||
|
||||
// FileSum is one regular file of the data directory as the archive holds it,
|
||||
// named as in the archive (data/...).
|
||||
type FileSum struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// archive is the tar stream a backup is written to, and the record of the
|
||||
// data directory's files written to it so far. A nil *archive writes
|
||||
// nothing: every file is opened and closed, which is how Check finds what
|
||||
// cannot be read.
|
||||
//
|
||||
// Only the data directory is recorded. The deploy files are the operator's to
|
||||
// edit before the first start - the guide sends them to .env for a new
|
||||
// hostname and to docker-compose.yml for the tag - and the data directory is
|
||||
// what the panel opens.
|
||||
type archive struct {
|
||||
tw *tar.Writer
|
||||
files []FileSum
|
||||
}
|
||||
|
||||
// writeFile writes one regular file's header and content, read from r, and
|
||||
// records it when it belongs to the data directory.
|
||||
func (a *archive) writeFile(hdr *tar.Header, r io.Reader) error {
|
||||
if err := a.tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("backup: write header %s: %w", hdr.Name, err)
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(a.tw, h), r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: copy %s: %w", hdr.Name, err)
|
||||
}
|
||||
if strings.HasPrefix(hdr.Name, DataArchivePrefix) {
|
||||
a.files = append(a.files, FileSum{Name: hdr.Name, Size: n, SHA256: hex.EncodeToString(h.Sum(nil))})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeContents ends the archive's data with the record of what it holds.
|
||||
func (a *archive) writeContents() error {
|
||||
body, err := json.MarshalIndent(Contents{Format: FormatContents, Files: a.files}, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: encode contents: %w", err)
|
||||
}
|
||||
return writeTarBytes(&archive{tw: a.tw}, DataArchivePrefix+ContentsName, 0o600, body)
|
||||
}
|
||||
|
||||
// errUnpackedPart is what a restore that is not whole is refused with.
|
||||
func errUnpackedPart(detail string) error {
|
||||
return fmt.Errorf("backup: the archive was not unpacked whole (%s) - it was cut short or is damaged; nothing was opened. Unpack it again, from an intact copy, into an empty directory", detail)
|
||||
}
|
||||
|
||||
// checkContents compares the data directory with the archive's record, once
|
||||
// per restore (see contentsCheckedName). A record unpacked anew wins over the
|
||||
// mark of an earlier check: the archive was unpacked again, and that is what
|
||||
// the start runs on (review of audit stage 3).
|
||||
func checkContents(dataDir string) error {
|
||||
path := filepath.Join(dataDir, ContentsName)
|
||||
checked := filepath.Join(dataDir, contentsCheckedName)
|
||||
c, err := readContents(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
prev, perr := readContents(checked)
|
||||
if perr == nil && verifyFiles(dataDir, prev, DataArchivePrefix+ManifestName) == nil {
|
||||
// A start before this one checked the files, and the manifest is
|
||||
// still the one that record holds: the same restore, whose
|
||||
// files that start may have changed since.
|
||||
return nil
|
||||
}
|
||||
if perr != nil && !errors.Is(perr, os.ErrNotExist) {
|
||||
return perr
|
||||
}
|
||||
return errUnpackedPart(DataArchivePrefix + ContentsName + ", its last entry, is not there")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// No backup carries the database's journal files. One beside the
|
||||
// restored database is left from the database the directory held
|
||||
// before, and opening this one would take it up: the first connection
|
||||
// after a crash recovers from the WAL, and a checkpoint "takes content
|
||||
// from the WAL file and transfers it back into the original database
|
||||
// file" (https://sqlite.org/wal.html § 9, § 2.2).
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Lstat(filepath.Join(dataDir, "selfpost.db"+side)); err == nil {
|
||||
return errUnpackedPart("selfpost.db" + side + " is beside the database, and no archive holds one - it is left from what was in the directory before")
|
||||
}
|
||||
}
|
||||
if err := verifyFiles(dataDir, c, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(path, checked); err != nil {
|
||||
return fmt.Errorf("backup: mark the restored files checked: %w", err)
|
||||
}
|
||||
// The start goes on to change the files; the mark must not be lost to a
|
||||
// power cut after that, or the next start compares changed files.
|
||||
_ = atomicfile.SyncDir(dataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readContents reads a record of the archive's files.
|
||||
func readContents(path string) (Contents, error) {
|
||||
var c Contents
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &c); err != nil || c.Format != FormatContents {
|
||||
return c, errUnpackedPart(filepath.Base(path) + " is not a record of the archive's files")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// verifyFiles compares the files c records with those under dataDir: each
|
||||
// present, of its size, with its digest. A non-empty only limits it to the
|
||||
// file of that name.
|
||||
func verifyFiles(dataDir string, c Contents, only string) error {
|
||||
found := false
|
||||
for _, f := range c.Files {
|
||||
if only != "" && f.Name != only {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rel, ok := strings.CutPrefix(f.Name, DataArchivePrefix)
|
||||
if !ok || rel == "" || path.Clean(rel) != rel || rel == ".." || strings.HasPrefix(rel, "../") || path.IsAbs(rel) {
|
||||
return errUnpackedPart("the record names " + f.Name + ", which is not a file of the data directory")
|
||||
}
|
||||
// The archive holds regular files only (archive.writeFile); a link
|
||||
// or a pipe under the name is not followed or read - a crafted
|
||||
// archive must not make the start read a file outside /data, or
|
||||
// wait on a pipe (security review of audit stage 3).
|
||||
full := filepath.Join(dataDir, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(full)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errUnpackedPart(f.Name + " is missing")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != f.Size {
|
||||
return errUnpackedPart(f.Name + " is not the file the archive holds")
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, io.LimitReader(file, f.Size+1))
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
if n != f.Size || hex.EncodeToString(h.Sum(nil)) != f.SHA256 {
|
||||
return errUnpackedPart(f.Name + " is not the file the archive holds")
|
||||
}
|
||||
}
|
||||
if only != "" && !found {
|
||||
return errUnpackedPart("the record does not name " + only)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func reportXML(orgName, domain, records string) string {
|
||||
return `<?xml version="1.0"?><feedback><report_metadata><org_name>` + orgName +
|
||||
`</org_name><email>a@example.net</email><report_id>1</report_id>` +
|
||||
`<date_range><begin>1723593600</begin><end>1723680000</end></date_range></report_metadata>` +
|
||||
`<policy_published><domain>` + domain + `</domain><p>none</p></policy_published>` + records + `</feedback>`
|
||||
}
|
||||
|
||||
const oneRecord = `<record><row><source_ip>203.0.113.10</source_ip><count>3</count>` +
|
||||
`<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated></row>` +
|
||||
`<identifiers><header_from>example.com</header_from></identifiers></record>`
|
||||
|
||||
// The XML is a stranger's: what it may make the relay store is bounded.
|
||||
func TestParseAggregateBoundsWhatAReportHolds(t *testing.T) {
|
||||
long := strings.Repeat("é", 4000)
|
||||
got, err := ParseAggregate([]byte(reportXML(long, "example.com", oneRecord)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Reporter) > maxFieldLen || !strings.HasPrefix(long, got.Reporter) || got.Reporter == "" {
|
||||
t.Fatalf("reporter name is %d bytes, want the start of the name in at most %d", len(got.Reporter), maxFieldLen)
|
||||
}
|
||||
if strings.ContainsRune(got.Reporter, '�') {
|
||||
t.Fatal("the name was cut in the middle of a character")
|
||||
}
|
||||
|
||||
huge := `<record><row><source_ip>203.0.113.10</source_ip><count>9223372036854775807</count>` +
|
||||
`<policy_evaluated><dkim>pass</dkim></policy_evaluated></row></record>`
|
||||
got, err = ParseAggregate([]byte(reportXML("r", "example.com", huge+huge)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PassCount != 2*maxRecordCount {
|
||||
t.Fatalf("pass count = %d, want two records clamped to %d each", got.PassCount, maxRecordCount)
|
||||
}
|
||||
|
||||
if _, err := ParseAggregate([]byte(reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords)))); err != nil {
|
||||
t.Fatalf("a report of %d records was refused: %v", maxReportRecords, err)
|
||||
}
|
||||
if _, err := ParseAggregate([]byte(reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords+1)))); err == nil {
|
||||
t.Fatalf("a report of %d records was accepted", maxReportRecords+1)
|
||||
}
|
||||
}
|
||||
|
||||
// A report over the bound is one the relay does not keep: dropped, not retried.
|
||||
func TestIngestMessageRejectsAReportWithTooManyRecords(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)
|
||||
}
|
||||
xml := reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords+1))
|
||||
err = IngestMessage(st, strings.NewReader(gzipMessage(t, xml)), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
|
||||
if !errors.Is(err, ErrRejected) {
|
||||
t.Fatalf("err = %v, want ErrRejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reports filed under one domain do not push another domain's reports out.
|
||||
func TestIngestKeepsEachDomainsReportsApart(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
now := time.Now().UTC()
|
||||
if _, err := st.InsertDMARCReport(store.DMARCReport{
|
||||
Domain: "quiet.example", Reporter: "genuine", ReportID: "1", PeriodBegin: now, PeriodEnd: now, ReceivedAt: now.Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < store.DMARCReportsMaxKeep+20; i++ {
|
||||
if _, err := st.InsertDMARCReport(store.DMARCReport{
|
||||
Domain: "flooded.example", Reporter: "stranger", ReportID: strings.Repeat("x", 1+i), PeriodBegin: now, PeriodEnd: now, ReceivedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.PruneDMARCReports("flooded.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quiet, err := st.ListDMARCReports(store.DMARCReportScope{Domains: []string{"quiet.example"}}, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(quiet) != 1 {
|
||||
t.Fatalf("the other domain has %d reports after the flood, want its 1", len(quiet))
|
||||
}
|
||||
flooded, err := st.ListDMARCReports(store.DMARCReportScope{Domains: []string{"flooded.example"}}, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(flooded) > store.DMARCReportsMaxKeep {
|
||||
t.Fatalf("the flooded domain keeps %d reports, want at most %d", len(flooded), store.DMARCReportsMaxKeep)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dmarc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -16,6 +17,37 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// ErrRejected marks a message that is not a report this relay keeps and never
|
||||
// will be: it cannot be read as an aggregate report, or it reports on a domain
|
||||
// that is not sent from here, or it came to another domain's address. Sending
|
||||
// it again changes nothing, so the caller drops it. Any other error is the
|
||||
// store's and worth another try.
|
||||
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.
|
||||
//
|
||||
@@ -28,17 +60,20 @@ import (
|
||||
// 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)
|
||||
}
|
||||
payload, err := extractAggregatePayload(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
return rejected(err)
|
||||
}
|
||||
parsed, err := ParseAggregate(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return rejected(err)
|
||||
}
|
||||
recipient = strings.ToLower(strings.TrimSpace(recipient))
|
||||
if err := checkReportDomain(st, parsed.Domain, recipient); err != nil {
|
||||
@@ -74,7 +109,7 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
|
||||
if _, err := st.InsertDMARCReport(rep); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.PruneDMARCReports()
|
||||
return st.PruneDMARCReports(rep.Domain)
|
||||
}
|
||||
|
||||
// checkReportDomain refuses a report whose policy domain is not one of this
|
||||
@@ -82,17 +117,17 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
|
||||
// delivered to.
|
||||
func checkReportDomain(st *store.Store, domain, recipient string) error {
|
||||
if domain == "" {
|
||||
return fmt.Errorf("dmarc report: no policy domain")
|
||||
return rejected(fmt.Errorf("no policy domain"))
|
||||
}
|
||||
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
|
||||
return fmt.Errorf("dmarc report: domain %q does not match the address it was sent to (%s)", domain, recipient)
|
||||
return rejected(fmt.Errorf("domain %q does not match the address it was sent to (%s)", domain, recipient))
|
||||
}
|
||||
ok, err := st.DomainExists(domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("dmarc report: %q is not a domain configured on this relay", domain)
|
||||
return rejected(fmt.Errorf("%q is not a domain configured on this relay", domain))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -144,6 +179,10 @@ func extractAggregatePayload(raw []byte) ([]byte, error) {
|
||||
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
|
||||
}
|
||||
|
||||
// looksLikeAggregate takes a part by its file name or Content-Type. RFC 7489
|
||||
// § 7.2.1.1: the report is "application/gzip" if compressed and "text/xml"
|
||||
// otherwise, its name ending ".xml" or ".xml.gz"; zip is outside the RFC and
|
||||
// is what some reporters send (see decompress).
|
||||
func looksLikeAggregate(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||
@@ -158,7 +197,10 @@ func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||
case "quoted-printable":
|
||||
body = quotedprintable.NewReader(r)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
||||
data, err := readAtMost(body, maxAggregateXML)
|
||||
if errors.Is(err, errTooLarge) {
|
||||
return nil, fmt.Errorf("the report part is larger than %d MiB", maxAggregateXML>>20)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read part body: %w", err)
|
||||
}
|
||||
|
||||
+89
-28
@@ -5,10 +5,12 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
||||
@@ -39,6 +41,11 @@ type ParsedRecord struct {
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
// feedbackXML follows RFC 7489, Appendix C. The schema's targetNamespace is
|
||||
// "http://dmarc.org/dmarc-xml/0.1"; a tag without a namespace matches the
|
||||
// local name in any namespace (Go, src/encoding/xml/read.go: the namespace is
|
||||
// compared only when the tag names one), so a report that declares one is
|
||||
// read the same.
|
||||
type feedbackXML struct {
|
||||
XMLName xml.Name `xml:"feedback"`
|
||||
Metadata struct {
|
||||
@@ -51,12 +58,12 @@ type feedbackXML struct {
|
||||
} `xml:"date_range"`
|
||||
} `xml:"report_metadata"`
|
||||
Policy struct {
|
||||
Domain string `xml:"domain"`
|
||||
P string `xml:"p"`
|
||||
SP string `xml:"sp"`
|
||||
Pct int `xml:"pct"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
Domain string `xml:"domain"`
|
||||
P string `xml:"p"`
|
||||
SP string `xml:"sp"`
|
||||
Pct *string `xml:"pct"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
} `xml:"policy_published"`
|
||||
Records []struct {
|
||||
Row struct {
|
||||
@@ -74,6 +81,34 @@ type feedbackXML struct {
|
||||
} `xml:"record"`
|
||||
}
|
||||
|
||||
// What a report may hold. The XML comes from anyone who knows a report address,
|
||||
// and eight megabytes of it (maxAggregateXML) fit some 160 000 empty records or
|
||||
// one name of eight megabytes - every one of them a row or a table cell later.
|
||||
// A real report has tens of records, a few thousand from the largest
|
||||
// receivers, and no field longer than a line - an estimate: the live
|
||||
// installation had received one report by 2026-10-15, of one record, which
|
||||
// says nothing yet about the largest (assumptions C-M8: keep this ten times
|
||||
// above the largest seen).
|
||||
const (
|
||||
maxReportRecords = 5000
|
||||
maxFieldLen = 255
|
||||
maxRecordCount = 1_000_000_000
|
||||
)
|
||||
|
||||
// field trims a text value of the report and cuts it to maxFieldLen bytes, on
|
||||
// a character boundary.
|
||||
func field(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= maxFieldLen {
|
||||
return s
|
||||
}
|
||||
s = s[:maxFieldLen]
|
||||
for len(s) > 0 && !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
|
||||
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||
data, err := decompress(raw)
|
||||
@@ -87,34 +122,50 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
||||
}
|
||||
if len(doc.Records) > maxReportRecords {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: %d records, more than the %d a report may hold", len(doc.Records), maxReportRecords)
|
||||
}
|
||||
// Cut, not refused: a domain name is at most 253 bytes, so a longer one
|
||||
// names nothing this relay sends for and is turned away as such.
|
||||
out := ParsedReport{
|
||||
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
|
||||
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
|
||||
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
|
||||
Reporter: field(doc.Metadata.OrgName),
|
||||
ReportID: field(doc.Metadata.ReportID),
|
||||
ContactEmail: field(doc.Metadata.Email),
|
||||
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
||||
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
||||
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
|
||||
PolicyP: strings.TrimSpace(doc.Policy.P),
|
||||
PolicySP: strings.TrimSpace(doc.Policy.SP),
|
||||
PolicyPct: doc.Policy.Pct,
|
||||
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
|
||||
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
|
||||
Domain: strings.ToLower(field(doc.Policy.Domain)),
|
||||
PolicyP: field(doc.Policy.P),
|
||||
PolicySP: field(doc.Policy.SP),
|
||||
PolicyADKIM: field(doc.Policy.ADKIM),
|
||||
PolicyASPF: field(doc.Policy.ASPF),
|
||||
}
|
||||
if out.PolicyPct == 0 {
|
||||
out.PolicyPct = 100
|
||||
// RFC 7489 § 6.3: pct is "between 0 and 100, inclusive", 100 when absent.
|
||||
// An explicit 0 is a policy applied to no message, and stays 0; a report
|
||||
// without the element, or with an empty or unreadable one, gets the
|
||||
// default, and a value outside the range is held to it (assumptions
|
||||
// C-M7; review of stage 2c, F9).
|
||||
out.PolicyPct = 100
|
||||
if doc.Policy.Pct != nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(*doc.Policy.Pct)); err == nil {
|
||||
out.PolicyPct = min(max(n, 0), 100)
|
||||
}
|
||||
}
|
||||
for _, rec := range doc.Records {
|
||||
row := ParsedRecord{
|
||||
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
|
||||
SourceIP: field(rec.Row.SourceIP),
|
||||
Count: rec.Row.Count,
|
||||
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
|
||||
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
|
||||
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
|
||||
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
|
||||
Disposition: field(rec.Row.Policy.Disposition),
|
||||
SPFResult: field(rec.Row.Policy.SPF),
|
||||
DKIMResult: field(rec.Row.Policy.DKIM),
|
||||
HeaderFrom: field(rec.Identifiers.HeaderFrom),
|
||||
}
|
||||
if row.Count <= 0 {
|
||||
row.Count = 1
|
||||
}
|
||||
// Bounded so that the sums of a report cannot wrap around.
|
||||
if row.Count > maxRecordCount {
|
||||
row.Count = maxRecordCount
|
||||
}
|
||||
if dmarcRecordPasses(row) {
|
||||
out.PassCount += row.Count
|
||||
} else {
|
||||
@@ -125,6 +176,9 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dmarcRecordPasses: RFC 7489, Appendix C, PolicyEvaluatedType - dkim and spf
|
||||
// are "The DMARC-aligned authentication result"; § 4.2: DMARC passes when one
|
||||
// supported mechanism passes aligned.
|
||||
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
||||
}
|
||||
@@ -135,7 +189,8 @@ const maxAggregateXML = 8 << 20
|
||||
|
||||
// decompress unwraps the container the reporter used. Both are seen in the
|
||||
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
|
||||
// endpoints) ship it as a single-entry zip.
|
||||
// endpoints) ship it as a single-entry zip. (A sighting: where and when not
|
||||
// recorded. RFC 7489 § 7.2.1.1 names gzip only.)
|
||||
func decompress(raw []byte) ([]byte, error) {
|
||||
switch {
|
||||
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
|
||||
@@ -152,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)
|
||||
}
|
||||
@@ -172,12 +230,15 @@ func unzip(raw []byte) ([]byte, error) {
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc zip open %s: %w", f.Name, err)
|
||||
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 %s: %w", f.Name, err)
|
||||
return nil, fmt.Errorf("dmarc zip read %q: %w", field(f.Name), err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// sampleXML and the messages in this file are composed by hand, in the shape
|
||||
// RFC 7489 gives (Appendix C, the schema; § 7.2.1.1, application/gzip and the
|
||||
// .xml.gz name). The org name is any reporter's; no report was copied.
|
||||
const sampleXML = `<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
@@ -126,8 +131,9 @@ func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()); err == nil {
|
||||
t.Fatal("report for an unconfigured domain was accepted")
|
||||
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC())
|
||||
if !errors.Is(err, ErrRejected) {
|
||||
t.Fatalf("report for an unconfigured domain: err = %v, want ErrRejected", err)
|
||||
}
|
||||
list, err := st.ListDMARCReports(store.DMARCReportScope{AllDomains: true}, 10)
|
||||
if err != nil {
|
||||
@@ -155,8 +161,47 @@ func TestIngestMessageRejectsTagMismatch(t *testing.T) {
|
||||
|
||||
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)),
|
||||
"dmarc-reports+other.example@mail.example.com", time.Now().UTC())
|
||||
if err == nil {
|
||||
t.Fatal("report was accepted at an address tagged for another domain")
|
||||
if !errors.Is(err, ErrRejected) {
|
||||
t.Fatalf("report at an address tagged for another domain: err = %v, want ErrRejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What cannot be read as a report is rejected, not put off: Postfix would
|
||||
// offer the same bytes again for days.
|
||||
func TestIngestMessageRejectsWhatIsNotAReport(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)
|
||||
}
|
||||
for name, msg := range map[string]string{
|
||||
"plain text": "From: someone@example.net\r\nSubject: hello\r\n\r\nnot a report\r\n",
|
||||
"no headers": "garbage",
|
||||
"broken gzip": "From: a@example.net\r\nContent-Type: application/gzip\r\n\r\nnot gzip at all",
|
||||
} {
|
||||
err := IngestMessage(st, bytes.NewBufferString(msg), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
|
||||
if !errors.Is(err, ErrRejected) {
|
||||
t.Errorf("%s: err = %v, want ErrRejected", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failing store is not the report's fault: the message is to be tried again.
|
||||
func TestIngestMessageStoreFailureIsNotARejection(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
|
||||
if err == nil || errors.Is(err, ErrRejected) {
|
||||
t.Fatalf("closed store: err = %v, want an error that is not ErrRejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,3 +259,31 @@ func gzipMessage(t *testing.T, xml string) string {
|
||||
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||
"\r\n" + gz.String()
|
||||
}
|
||||
|
||||
// pct is 100 when the report leaves it out (RFC 7489 § 6.3), and an explicit
|
||||
// 0 - a policy applied to no message - stays 0 (assumptions C-M7).
|
||||
func TestPolicyPct(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pct string
|
||||
want int
|
||||
}{
|
||||
{"<pct>100</pct>", 100},
|
||||
{"<pct>0</pct>", 0},
|
||||
{"<pct>25</pct>", 25},
|
||||
{"", 100},
|
||||
{"<pct/>", 100},
|
||||
{"<pct> 50 </pct>", 50},
|
||||
{"<pct>150</pct>", 100},
|
||||
{"<pct>-5</pct>", 0},
|
||||
{"<pct>x</pct>", 100},
|
||||
} {
|
||||
doc := strings.Replace(sampleXML, "<pct>100</pct>", c.pct, 1)
|
||||
got, err := ParseAggregate([]byte(doc))
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", c.pct, err)
|
||||
}
|
||||
if got.PolicyPct != c.want {
|
||||
t.Errorf("%q: PolicyPct %d, want %d", c.pct, got.PolicyPct, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user