panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Waiting to run

The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 01:05:39 +03:00
co-authored by Claude Opus 5.5
parent 005922bc7d
commit faa3c11ae7
96 changed files with 971 additions and 2247 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ name: release
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z. # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# #
# Native per-architecture builds (see docs/development.md), not qemu: running # Native per-architecture builds (see docs/development.md), not qemu: running
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is # the full Postfix and panel stack under emulation for the e2e gate below is
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its # impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# own native runner; a merge job then combines them into the one manifest tag # own native runner; a merge job then combines them into the one manifest tag
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
+26 -7
View File
@@ -8,13 +8,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added ### Added
- **The panel signs outgoing mail with DKIM itself** (panel-dkim-signing, - **The panel signs outgoing mail with DKIM itself** (panel-dkim-signing,
stage 1 of [the plan](docs/plans/panel-dkim-signing.md)): a milter of the [the plan](docs/plans/panel-dkim-signing.md)): a milter of the panel's on
panel's on two sockets, one for applications' mail - signed with the key of two sockets, one for applications' mail - signed with the key of the
the domain the login belongs to - and one for the panel's own, signed with domain the login belongs to - and one for the panel's own, signed with the
the server's name. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From` server's name. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From`
once more, as OpenDKIM signs; strict. Until OpenDKIM leaves the image both once more, as OpenDKIM signed; strict. Before OpenDKIM left, both signed
sign every message, and the e2e suite holds the two to the same `h=` and every message on the e2e stand and agreed on `h=` and `bh=`; dkimpy - an
`bh=` and has dkimpy - an independent implementation - verify each. independent implementation - verifies the panel's signature on every run.
### Removed
- **OpenDKIM** - a 2018 beta with open 2026 CVEs and no maintainer - and what
SelfPost kept around it: the syslog relay and `/dev/log`, the reload by a
signal through supervisord, the port of OpenDKIM's `From:` parser.
Server › Health shows the signer's two sockets in its place.
### Changed
- **The DKIM keys moved to `/data/dkim`** and are the panel's alone (0600):
the first start copies them from `/data/opendkim`, which is left as it was
for a rollback to 2.7.x. Keys, selectors and DNS records do not change.
The KeyTable and SigningTable are gone; a selector table the signer reads
takes their place. `OPENDKIM_DIR` and `OPENDKIM_SOCKET` are gone.
- **The sender check reads `From:` as a mail program does** and no longer as
OpenDKIM did: the line must be one address at a domain the application
may send as, and the 256-byte limit of OpenDKIM's buffer goes. The key is
the login's, so no `From:` line can choose another tenant's.
### Fixed ### Fixed
+15 -18
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# SelfPost — single Debian-slim image running postfix + opendkim + panel under # SelfPost — single Debian-slim image running postfix + panel under
# supervisord. Build from the repository root: # supervisord. Build from the repository root:
# #
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
@@ -52,8 +52,9 @@ ENV DEBIAN_FRONTEND=noninteractive
# the panel runs (internal/app/sasl.go); that db-util ships that path on # 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 # trixie is not in a source read here - the image build and the e2e sasldb
# test say whether it does. # test say whether it does.
# opendkim-tools is not installed: nothing in the image runs its programs # No DKIM package is installed: the panel signs itself (internal/milter,
# (DKIM keys are made in Go, internal/domain/dkim.go). # sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
# the image in 2.8.0 (docs/plans/panel-dkim-signing.md).
# systemd-standalone-sysusers is named so that cron-daemon-common, which # systemd-standalone-sysusers is named so that cron-daemon-common, which
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron | # logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather # anacron | cron-daemon | systemd-sysv"), creates its group with it rather
@@ -65,7 +66,6 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
postfix \ postfix \
opendkim \
sasl2-bin \ sasl2-bin \
libsasl2-modules \ libsasl2-modules \
db-util \ db-util \
@@ -94,13 +94,13 @@ RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
# back to it by name on every start, so files of the old ids follow. # 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 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 # Shared group bridging the unprivileged services: the panel writes the
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix # application SASL accounts (sasldb2) and the Postfix sender map and opens the
# sender map, while OpenDKIM and Postfix (different users) must read them. # milter sockets, while Postfix (another user) must read and reach them.
# Membership in this group - plus setgid dirs under /data (set up in # Membership in this group - plus setgid dirs under /data and /run/selfpost
# entrypoint.sh) - lets OpenDKIM read the panel-owned keys and lets Postfix # (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
# read the sasldb2/sender map. (The supervisor control socket has a group of # connect to the panel's milters. The DKIM keys are the panel's alone. (The
# its own, `supervisor`, below.) # 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 # 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 # 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 # it into a full backup (guide § Taking a full backup). It lies outside the
@@ -117,7 +117,6 @@ RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid p
# fail this build rather than share the group. # fail this build rather than share the group.
RUN groupadd --system --gid 10998 selfpost \ RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \ && usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (and the status page's # The panel reads the mail queue with `postqueue -p` (and the status page's
@@ -138,14 +137,13 @@ RUN groupadd --system --gid 10998 selfpost \
# with no_new_privs, execve() no longer changes the gid for a setgid file.) # with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
# The supervisord control socket's group (supervisord.conf, [unix_http_server]): # The supervisord control socket's group (supervisord.conf, [unix_http_server]):
# the panel alone - not OpenDKIM's or Postfix's users, which share `selfpost` # the panel alone - not Postfix's user, which shares `selfpost` for the files
# for the files under /data. # under /data.
RUN groupadd --system supervisor \ RUN groupadd --system supervisor \
&& usermod -aG supervisor panel && usermod -aG supervisor panel
# Runtime directories: milter sockets and the consolidated persistent root. # Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \ RUN mkdir -p /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \
&& chown panel:panel /run/selfpost /data && chown panel:panel /run/selfpost /data
COPY --from=build /out/panel /usr/local/bin/panel COPY --from=build /out/panel /usr/local/bin/panel
@@ -162,7 +160,6 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config # POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
# that logrotate would silently ignore — see docs/development.md § Building # that logrotate would silently ignore — see docs/development.md § Building
# binaries and the image. # binaries and the image.
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail 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-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-reload.sh /usr/local/bin/postfix-reload.sh
@@ -178,7 +175,7 @@ COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off. # Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25 EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). # Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
+4 -5
View File
@@ -5,7 +5,7 @@ If a managed program of the mail path exhausts its restart retries and
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
terminate, so the whole container exits and Docker's `restart` policy starts it terminate, so the whole container exits and Docker's `restart` policy starts it
again, rather than lingering "alive" with a dead Postfix/OpenDKIM/panel. again, rather than lingering "alive" with a dead Postfix or panel.
FATAL alone did not cover the second: a program reaches FATAL only through 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 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, ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
@@ -37,10 +37,9 @@ import time
# Programs whose failure does not take the container down # Programs whose failure does not take the container down
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload # (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 # 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 # it - and cert-reload looks for a renewed certificate once a day (a crash of
# looks for a renewed certificate once a day (a crash of it leaves the one # it leaves the one Postfix serves).
# Postfix serves). SPARED = {"logrotate", "postfix-reload", "cert-reload"}
SPARED = {"logrotate", "postfix-reload", "syslog-relay", "cert-reload"}
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a # 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 # program that does not stay up. A program on the mail path restarts in
+2 -2
View File
@@ -75,7 +75,7 @@ func TestCrashexitCountsCrashes(t *testing.T) {
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
calm = append(calm, exit("panel", "1"), exit("logrotate", "0")) calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
} }
calm = append(calm, exit("opendkim", "0"), exit("opendkim", "0"), exit("opendkim", "0")) calm = append(calm, exit("postfix", "0"), exit("postfix", "0"), exit("postfix", "0"))
if got := runListener(t, calm...); strings.Contains(got, "would shut down") { 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) t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
} }
@@ -89,7 +89,7 @@ func TestCrashexitFatal(t *testing.T) {
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") { 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) t.Errorf("postfix FATAL:\n%s", got)
} }
if got := runListener(t, fatal("logrotate"), fatal("syslog-relay")); strings.Contains(got, "would shut down") { if got := runListener(t, fatal("logrotate"), fatal("cert-reload")); strings.Contains(got, "would shut down") {
t.Errorf("a spared program's FATAL shut down:\n%s", got) t.Errorf("a spared program's FATAL shut down:\n%s", got)
} }
} }
+44 -48
View File
@@ -58,10 +58,11 @@ esac
# — the one place still running as root — before handing off to supervisord, # — the one place still running as root — before handing off to supervisord,
# which starts the panel as the panel user. # which starts the panel as the panel user.
# #
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their # Mode must stay world-traversable (0755): Postfix reaches its trees under
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare # /data as another user. Go's testing.TempDir is 0700, and a bare chown would
# chown would leave that mode in place — opendkim then cannot read KeyTable and # leave that mode in place — OpenDKIM, which read its tables there until
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). # 2.8.0, then could not, and the container crash-looped (e2e
# TestHostnameGate/valid_hostname_starts).
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with # (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
# mode 0o700".) # mode 0o700".)
chown panel:panel /data chown panel:panel /data
@@ -85,8 +86,8 @@ drop_links() {
fi fi
done done
} }
drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \ drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/opendkim/keys /data/opendkim/KeyTable /data/opendkim/SigningTable \ /data/dkim/keys /data/dkim/selectors /data/opendkim/keys \
/data/sasl/sasldb2 /data/postfix/queue \ /data/sasl/sasldb2 /data/postfix/queue \
/data/postfix/sender_login_maps /data/postfix/relay_domains \ /data/postfix/sender_login_maps /data/postfix/relay_domains \
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \ /data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
@@ -95,7 +96,8 @@ drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
# under /data; make sure they stay writable without disturbing anything that a # 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 # 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 # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below, as are the trees shared through group selfpost. # normalised on its own below, as are the trees shared through group selfpost
# and the one OpenDKIM read until 2.8.0, which is left as it was (below).
# A file's owner is a number: an entry whose user or group is not the panel's # 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 # 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 # image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
@@ -103,24 +105,31 @@ drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \ 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 {} + \( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
# DKIM key tree. The panel (user `panel`) generates keys and writes # DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # the keys, writes the table and signs (internal/domain, Keys; internal/milter,
# tree on every start so it is correct whether /data is fresh, restarted, or # sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with
# just restored from a backup: # OpenDKIM through group selfpost. The first start without /data/dkim copies
# - group `selfpost` + setgid on directories so anything the panel creates # them over and leaves the old tree as it was, for an image of 2.7.x rolled
# inherits the shared group OpenDKIM traverses (inode(7), "The # back to (docs/plans/panel-dkim-signing.md, Q2). The copy is made as the
# set-group-ID bit": files created in such a directory take its group); # panel user, which can read nothing there the panel could not, into a
# - private keys and tables group-readable (0640); # directory beside, renamed into place when it is whole: a start that stops
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the # midway copies again. A copy that fails stops the start (set -e) - the
# daemon comes up cleanly with no domains yet. # panel would otherwise make the server's own name a new key, and its
mkdir -p /data/opendkim/keys # published record would no longer match. The selector table is written by
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do # the panel before its signer serves (cmd/panel/signer.go).
[ -e "$t" ] || : > "$t" # (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
done # -P: never follow a symbolic link in the source.)
chown -R panel:selfpost /data/opendkim rm -rf /data/dkim.new
find /data/opendkim -type d -exec chmod 2750 {} + if [ ! -e /data/dkim ] && [ -d /data/opendkim/keys ]; then
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable setpriv --reuid=panel --regid=panel --clear-groups -- \
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + sh -c 'mkdir /data/dkim.new && cp -RP /data/opendkim/keys /data/dkim.new/keys'
mv /data/dkim.new /data/dkim
echo "entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback" >&2
fi
mkdir -p /data/dkim/keys
chown -R panel:panel /data/dkim
find /data/dkim -type d -exec chmod 0700 {} +
find /data/dkim -type f -exec chmod 0600 {} +
# Application SASL accounts. The panel (user `panel`) writes the # Application SASL accounts. The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP # sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
@@ -190,18 +199,16 @@ chown -R postfix:selfpost /data/log
chmod 2750 /data/log chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} + find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to # The milter socket directory. Postfix (user `postfix`) must actually CONNECT
# the milter sockets — OpenDKIM's and the panel's — not just probe them at # to the panel's milter sockets, not just probe them at start-up. The panel
# start-up. The sockets are created by the opendkim and panel # user creates them, so they are bridged to `postfix` through the shared
# users respectively, so bridge them to `postfix` through the shared `selfpost` # `selfpost` group: a group-owned + setgid dir means each socket created
# group: group-owned + setgid dirs mean each socket created inside inherits # inside inherits group `selfpost`, and group-traversable (2750) lets postfix
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without # reach it. Without this, smtpd cannot talk to the signer and, because
# this, smtpd cannot talk to OpenDKIM and, because signing is strict # signing is strict (default_action=tempfail), defers all mail.
# (default_action=tempfail), rejects all mail. mkdir -p /run/selfpost
mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost chown panel:selfpost /run/selfpost
chmod 2750 /run/opendkim /run/selfpost chmod 2750 /run/selfpost
# A container that was killed leaves its sockets behind, and a file nobody # 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 # 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 # before the sender check does and defer mail until it is there. The check's
@@ -210,18 +217,7 @@ chmod 2750 /run/opendkim /run/selfpost
# downtime (guide § Health) - a mark left from before would count the whole # downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs. # outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.) # (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive rm -f /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 # 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 # 5). Kept out of the image build so cert paths, rate limits, hostname and the
-60
View File
@@ -1,60 +0,0 @@
# OpenDKIM — per-domain signing.
#
# 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. (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. (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.)
# 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. (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
Background no
+24 -28
View File
@@ -178,10 +178,9 @@ need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}" need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}" need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check # Milter sockets, all the panel's: its journal-milter (monitoring,
# (strict) and its journal-milter (monitoring, fail-open). Fixed container # fail-open), the sender check and the DKIM signer (both strict). Fixed
# paths, matched by postfix-wrapper.sh. # container paths, matched by postfix-wrapper.sh.
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# The panel's sender check (the From: line against the login, strict). # The panel's sender check (the From: line against the login, strict).
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
@@ -400,26 +399,23 @@ postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s" "anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail: # Milter chain. The sender check comes first and is strict
# if it is unreachable, defer rather than send unsigned). The journal-milter is # (default_action=tempfail): it holds the From: line against the login, the
# monitoring only and is fail-open (default_action=accept): its failure must # address a receiver holds the signature to, so a message it could not judge
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax. # must not be signed. While the panel does not answer on that socket, mail is
# deferred (docs/security.md).
# #
# The sender check comes first and is strict as well: it holds the From: line # The panel's DKIM signer follows, strict too: unreachable, it defers rather
# against the login, which is what OpenDKIM then chooses the key by, so a # than send unsigned (docs/plans/panel-dkim-signing.md). It signs with the
# message it could not judge must not reach the signer. While the panel does # key of the login's domain. The journal-milter is monitoring only and is
# not answer on that socket, mail is deferred (docs/security.md). # fail-open (default_action=accept): its failure must never block the relay.
# # Per-milter settings use Postfix 3.0+ brace syntax.
# The panel's DKIM signer follows the sender check, strict too
# (docs/plans/panel-dkim-signing.md). Until OpenDKIM leaves the image both
# sign (stage 1 of that plan): OpenDKIM after the panel, so that every
# message carries both signatures and the e2e suite compares them.
# #
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) - # non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
# the panel's own notifications, sent as postmaster@ the hostname. The # the panel's own notifications, sent as postmaster@ the hostname. The
# signer's own socket for it, and OpenDKIM, sign them with the key of the # signer's own socket for it signs them with the key of the server's own
# server's own name; the journal-milter is not on this chain, since they are # name; the journal-milter is not on this chain, since they are not an
# not an application's mail. Bounces Postfix # application's mail. Bounces Postfix
# generates itself do not pass it (internal_mail_filter_classes is empty). # 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 # Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
@@ -431,8 +427,8 @@ postconf -e \
postconf -e \ postconf -e \
"milter_protocol=6" \ "milter_protocol=6" \
"milter_default_action=tempfail" \ "milter_default_action=tempfail" \
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ "smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }" "non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }"
# A signed message is delivered as it was signed: Postfix's SMTP client would # A signed message is delivered as it was signed: Postfix's SMTP client would
# otherwise rewrite an 8-bit message as quoted-printable for a server that # otherwise rewrite an 8-bit message as quoted-printable for a server that
@@ -440,8 +436,8 @@ postconf -e \
# Postfix 3.10.13, src/global/mail_params.h:3088, DEF_DISABLE_MIME_OCONV 0 # Postfix 3.10.13, src/global/mail_params.h:3088, DEF_DISABLE_MIME_OCONV 0
# (the conversion is on by default); proto/postconf.proto:7992, # (the conversion is on by default); proto/postconf.proto:7992,
# disable_mime_output_conversion: "Disable the conversion of 8BITMIME format # disable_mime_output_conversion: "Disable the conversion of 8BITMIME format
# to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures were exposed # to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures, before the
# to it as well. # panel's, were exposed to it as well.
postconf -e "disable_mime_output_conversion=yes" postconf -e "disable_mime_output_conversion=yes"
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's # What Postfix tells a milter with MAIL FROM: its defaults plus the client's
@@ -456,7 +452,7 @@ postconf -e \
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies) # 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 # must fail open just like a crash, not stall mail acceptance until the Postfix
# defaults (300s content) elapse. With default_action per milter, a # defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # journal-milter hang then resolves to accept and a signer hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy # 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 # response time (signing/DB insert are sub-second), so they never fire in normal
# operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567, # operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
@@ -518,7 +514,7 @@ fi
# debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd", # debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
# uncommented; conf/master.cf:12). When both flags are off, remove # uncommented; conf/master.cf:12). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses # 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 # smtp/unix). When either is on: no SASL, no signing; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses. # configured domains/recipients; DMARC accepts only allow-listed report addresses.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_DOMAINS_SETTING="" RELAY_DOMAINS_SETTING=""
@@ -622,8 +618,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
"smtp_tls_policy_maps=" "smtp_tls_policy_maps="
fi fi
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is # Milters on port 25. main.cf's chain (sender check and signer strict +
# for the SUBMISSION path only: OpenDKIM must not gate someone else's # journal-milter) is for the SUBMISSION path only: the signer must not gate someone else's
# inbound mail (a signing outage would defer it), and the journal-milter # inbound mail (a signing outage would defer it), and the journal-milter
# would file inbound mail into the outbound send log under the *sender's* # would file inbound mail into the outbound send log under the *sender's*
# domain — polluting Deliveries and letting a forged From: consume that # domain — polluting Deliveries and letting a forged From: consume that
+4 -5
View File
@@ -2,8 +2,8 @@
# Postfix start wrapper: solves the cold-start race where Postfix would try to # Postfix start wrapper: solves the cold-start race where Postfix would try to
# reach the milter sockets before they are listening. # reach the milter sockets before they are listening.
# #
# It blocks until the milter sockets — OpenDKIM, the panel's sender check, # It blocks until the milter sockets — the panel's sender check, DKIM signer
# DKIM signer (two) and journal-milter and, with the inbound relay on, its # (two) and journal-milter and, with the inbound relay on, its
# inbound journal milter — # inbound journal milter —
# are present, then runs `postfix start-fg` and stays its parent. If they are # 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. # not ready within the timeout it exits non-zero WITHOUT starting Postfix.
@@ -17,10 +17,9 @@
# #
# This handles cold start only. Once Postfix runs, a milter that does not # 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 # 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. # deferred for the signer and the sender check, and passes the journal-milter.
set -eu set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}" SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
@@ -28,7 +27,7 @@ SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}" TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
INTERVAL=1 INTERVAL=1
SOCKS="$OPENDKIM_SOCK $JOURNAL_SOCK $SENDER_SOCK $SIGN_SOCK $SIGN_LOCAL_SOCK" SOCKS="$JOURNAL_SOCK $SENDER_SOCK $SIGN_SOCK $SIGN_LOCAL_SOCK"
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}" SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
fi fi
+10 -44
View File
@@ -1,8 +1,8 @@
; SelfPost process supervision. ; SelfPost process supervision.
; ;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens ; Start ORDER is enforced by priority=: the panel (which opens the sender
; the sender check's and the journal-milter's sockets), then the Postfix ; check's, the DKIM signer's and the journal-milter's sockets), then the
; wrapper — which additionally blocks until the milter sockets are ready ; Postfix wrapper — which additionally blocks until the milter sockets are ready
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst, ; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/ ; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one ; supervisord.py, runforever(), transitions every group in that order in one
@@ -17,7 +17,7 @@
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and ; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
; transition()): an exit after it is EXITED and simply restarted, for ever - ; 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, ; 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 ; postfix-reload's and cert-reload's failures are left on Server > Health
; (build/crashexit.py). ; (build/crashexit.py).
[supervisord] [supervisord]
@@ -29,12 +29,11 @@ pidfile=/run/supervisord.pid
loglevel=info loglevel=info
; The control socket is reachable by the `supervisor` group, whose one member ; The control socket is reachable by the `supervisor` group, whose one member
; is the panel (the unprivileged `panel` user): it asks supervisord to signal ; is the panel (the unprivileged `panel` user): it asks supervisord to run the
; OpenDKIM after it rewrites the DKIM tables, to run the Postfix reload, and ; Postfix reload, and for the processes' state. Whoever reaches the socket can
; for the processes' state. Whoever reaches the socket can stop, start or ; stop, start or restart any program, so it is not the shared `selfpost`
; restart any program, so it is not the shared `selfpost` group's: OpenDKIM ; group's: Postfix is in that one for the files under /data, and a fault in
; and Postfix are in that one for the files under /data, and a fault in either ; it must not reach every other program (audit 2026-10-12, stage 5). Not
; 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 ; reachable by world. No HTTP auth is configured because access is gated by
; socket permissions, not the network. (supervisor 4.2.5, ; socket permissions, not the network. (supervisor 4.2.5,
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's ; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
@@ -50,38 +49,6 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl] [supervisorctl]
serverurl=unix:///run/supervisor.sock 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]
; 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
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; user=panel also gives the panel its supplementary groups, selfpost and ; user=panel also gives the panel its supplementary groups, selfpost and
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py, ; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; drop_privileges(), os.setgroups() from the user's memberships in ; drop_privileges(), os.setgroups() from the user's memberships in
@@ -122,8 +89,7 @@ stderr_logfile_maxbytes=0
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix ; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; reload` itself, and it cannot signal the Postfix master directly: `postfix ; reload` itself, and it cannot signal the Postfix master directly: `postfix
; start-fg` forks a separate master process, so a signal to the supervised ; start-fg` forks a separate master process, so a signal to the supervised
; foreground process never reaches it (unlike OpenDKIM, which runs in the ; foreground process never reaches it. Instead the panel asks
; foreground as the supervised process itself). Instead the panel asks
; supervisord — over the group-accessible control socket — to run this program, ; supervisord — over the group-accessible control socket — to run this program,
; which runs the canonical `postfix reload` as root - or exits 75 when no ; 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 ; master runs (build/postfix-reload.sh). autostart is off (it only runs on
+7 -9
View File
@@ -27,16 +27,14 @@ type mailStack struct {
Lists *inbound.Lists Lists *inbound.Lists
DMARC *dmarc.Service DMARC *dmarc.Service
pf *postfix.Postfix pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool inboundEnabled bool
dmarcEnabled bool dmarcEnabled bool
} }
func newMailStack(cfg config, st *store.Store) *mailStack { func newMailStack(cfg config, st *store.Store) *mailStack {
pf := postfix.New(cfg.postfixDir) pf := postfix.New(cfg.postfixDir)
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
// The panel's notifications are sent as postmaster@ the server's own name, // The panel's notifications are sent as postmaster@ the server's own name,
// and signed as it. // and signed as it.
domains.SetServerName(cfg.hostname) domains.SetServerName(cfg.hostname)
@@ -45,15 +43,15 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
return &mailStack{ return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc, Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
Lists: inbound.NewLists(st, cfg.antispamDir), Lists: inbound.NewLists(st, cfg.antispamDir),
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled, pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
} }
} }
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and // Resync rebuilds the selector table and Postfix's maps from SQLite and
// reloads both daemons — the same work as the Status page's Reload button. // reloads Postfix — the same work as the Health page's Reload button.
func (m *mailStack) Resync() error { func (m *mailStack) Resync() error {
if err := m.Domains.Resync(); err != nil { if err := m.Domains.Resync(); err != nil {
return fmt.Errorf("opendkim resync: %w", err) return fmt.Errorf("dkim resync: %w", err)
} }
if err := m.Apps.Resync(); err != nil { if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err) return fmt.Errorf("postfix resync: %w", err)
@@ -78,7 +76,6 @@ func (m *mailStack) Resync() error {
func (m *mailStack) skipReloadForTest() { func (m *mailStack) skipReloadForTest() {
m.pf.SetReloadHook(func() error { return nil }) m.pf.SetReloadHook(func() error { return nil })
m.odk.SetReloadHook(func() error { return nil })
} }
// resyncAfterRestore runs one mail-path Resync on the first boot after a // resyncAfterRestore runs one mail-path Resync on the first boot after a
@@ -116,7 +113,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
TLSCertFile: cfg.tlsCertFile, TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile, TLSServedCertFile: cfg.tlsServedCertFile,
TLSReloadInterval: cfg.tlsReloadInterval, TLSReloadInterval: cfg.tlsReloadInterval,
OpenDKIMSocket: cfg.opendkimSocket, SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
JournalSocket: cfg.journalSocket, JournalSocket: cfg.journalSocket,
SenderSocket: cfg.senderSocket, SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays, SessionIdleDays: cfg.sessionIdleDays,
+9 -22
View File
@@ -28,7 +28,6 @@ import (
"github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/logtail" "github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/syslogrelay"
) )
func main() { func main() {
@@ -48,21 +47,12 @@ func main() {
} }
showVersion := flag.Bool("version", false, "print version and exit") 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() flag.Parse()
if *showVersion { if *showVersion {
fmt.Println(buildinfo.Version) fmt.Println(buildinfo.Version)
return 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.SetFlags(log.LstdFlags | log.LUTC)
log.SetPrefix("panel: ") log.SetPrefix("panel: ")
@@ -98,19 +88,17 @@ type config struct {
rateLimitMessagesPerIP int rateLimitMessagesPerIP int
rateLimitWindowSeconds int rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix // Read-only input to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror // serves. The default mirrors build/postfix-config.sh, so the status
// build/postfix-config.sh, so the status page checks exactly what Postfix // page checks exactly what Postfix was configured with.
// was configured with. tlsCertFile string
tlsCertFile string
opendkimSocket string
// tlsServedCertFile is the certificate Postfix serves (its copy of // tlsServedCertFile is the certificate Postfix serves (its copy of
// tlsCertFile), read from postconf at the start; tlsReloadInterval is // tlsCertFile), read from postconf at the start; tlsReloadInterval is
// how often build/postfix-cert-reload.sh looks for a renewal. // how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string tlsServedCertFile string
tlsReloadInterval time.Duration tlsReloadInterval time.Duration
opendkimDir string dkimDir string
dkimSelectorDef string dkimSelectorDef string
saslDBPath string saslDBPath string
@@ -184,11 +172,10 @@ func loadConfig() config {
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second, 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 // Per-domain DKIM state (architecture.md § DKIM signing): the keys and
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group). // the selector table, the layout entrypoint.sh prepares.
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
// Application SASL accounts and the Postfix sender map (architecture.md § // Application SASL accounts and the Postfix sender map (architecture.md §
@@ -419,7 +406,7 @@ func run() error {
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }}, {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(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) }}, {"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg) }}, {"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error { {"log-tailer", func(ctx context.Context) error {
return logtail.Run(ctx, cfg.mailLog, st, func() int { return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays) days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
-15
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"log"
"time" "time"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
@@ -21,20 +20,6 @@ func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
dns := dnscheck.New(cfg.dnsResolvers) 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{ n := notify.New(st, notify.Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) { Domain: func(d store.Domain) (dnscheck.Domain, error) {
rec, err := ms.Domains.DKIMRecord(d) rec, err := ms.Domains.DKIMRecord(d)
+2 -2
View File
@@ -127,7 +127,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
extract(t, archiveOf(t, seedPanelProject(t)), target) extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target) cfg := panelConfig(t, target)
key := filepath.Join(cfg.dataDir, "opendkim", "keys", restoreDomain, "selfpost.private") key := filepath.Join(cfg.dataDir, "dkim", "keys", restoreDomain, "selfpost.private")
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0) f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
if err != nil { if err != nil {
t.Fatalf("open key: %v", err) t.Fatalf("open key: %v", err)
@@ -139,7 +139,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
t.Error("a refused restore ran its Resync") t.Error("a refused restore ran its Resync")
return nil return nil
}) })
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private" want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) { if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want) t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
} }
+7 -7
View File
@@ -85,9 +85,9 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
// The archive carries the daemon files, and the first boot after restore // The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database. // re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{ for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} { } {
got, err := os.ReadFile(filepath.Join(r.dataDir, path)) got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil { if err != nil {
@@ -268,10 +268,10 @@ func seedPanelProject(t *testing.T) string {
} }
for path, content := range map[string]string{ for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent", filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} { } {
full := filepath.Join(dataDir, path) full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
+1 -2
View File
@@ -63,8 +63,7 @@ func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
} }
}() }()
// The two tables the mail path itself goes by: whom Postfix lets send as // The table the mail path itself goes by: whom Postfix lets send as what.
// what, and what OpenDKIM signs.
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath()) bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
return milter.ServeSender(ctx, ln, bind, st) return milter.ServeSender(ctx, ln, bind, st)
} }
+14 -5
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/milter" "github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
) )
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two // serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
@@ -18,11 +19,19 @@ import (
// name. Postfix attaches both strictly (default_action=tempfail, // name. Postfix attaches both strictly (default_action=tempfail,
// build/postfix-config.sh), so while this role is not serving, mail waits. // build/postfix-config.sh), so while this role is not serving, mail waits.
// //
// The keys and selectors come from the KeyTable under opendkimDir, which the // The keys and selectors come from the key tree and the selector table under
// panel writes for OpenDKIM too while both sign (docs/plans/panel-dkim-signing.md, // dkimDir (internal/domain, Keys). Before it serves, the table is made to
// stage 1). // hold the server's own name - and written at all on the first start of
func serveSigner(ctx context.Context, cfg config) error { // 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
keys := milter.NewSignKeys(filepath.Join(cfg.opendkimDir, "KeyTable"), filepath.Join(cfg.opendkimDir, "keys")) // (build/postfix-wrapper.sh), so no message meets the table before it.
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
if rebuilt, err := newMailStack(cfg, st).Domains.EnsureServerSigned(); err != nil {
// Mail waits: what the signer cannot find a key for it defers.
log.Printf("dkim-signer: the selector table: %v", err)
} else if rebuilt {
log.Printf("dkim-signer: the selector table was rewritten from the registry")
}
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath()) bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
server := strings.ToLower(cfg.hostname) server := strings.ToLower(cfg.hostname)
+13 -7
View File
@@ -103,7 +103,7 @@ services:
# `user: panel` or a fully read-only rootfs without breaking that startup # `user: panel` or a fully read-only rootfs without breaking that startup
# self-healing. What IS applied: no privilege escalation past what the # self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the # image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need: # small set the root startup phase and Postfix genuinely need:
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024; # - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data # - FOWNER — entrypoint then chmods those now panel-owned /data
@@ -113,12 +113,18 @@ services:
# - SETUID/SETGID — supervisord drops the panel to the unprivileged # - SETUID/SETGID — supervisord drops the panel to the unprivileged
# `panel` user; Postfix switches to its own users; # `panel` user; Postfix switches to its own users;
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group. # - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a # - KILL — root signals processes of other uids: supervisord
# different uid) on domain add/remove to reload its # stops and restarts the panel (supervisor 4.2.5,
# KeyTable/SigningTable; without it os.kill() fails # supervisor/process.py:467, options.kill()), and
# with EPERM even though the caller is root, because # Postfix's master ends its children, which run as
# the kernel's signal permission check for # `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
# cross-uid kill() still consults CAP_KILL. # master_sigdeath(); master_spawn.c:365,
# master_delete_children()). Without it kill() fails
# with EPERM although the caller is root (kill(2),
# capabilities(7); measured on the test server
# 2026-10-16: root with SETUID/SETGID and no KILL
# cannot kill a process of uid 65534). Until 2.8.0
# it also carried OpenDKIM's USR1 reload.
# FOWNER/FSETID are required by build/entrypoint.sh's permission # FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container # self-healing; without them chmod fails with EPERM and the container
# crash-loops on start. # crash-loops on start.
+2 -3
View File
@@ -1148,9 +1148,8 @@ unsupported rather than as a missing doc:
and `build/postfix-wrapper.sh`, which must agree), `MAIL_LOG` 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 (`/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`), here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET` `PANEL_COOKIE_SECURE` (`true`), `DKIM_DIR` (`/data/dkim` — the keys and
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`), the selector table), `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
-3
View File
@@ -253,9 +253,6 @@ re-read what changed, update the citations, then this table:
| Package | Version read | | Package | Version read |
|---|---| |---|---|
| `postfix` | `3.10.13-0+deb13u1` | | `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` | | `libsasl2-2` | `2.1.28+dfsg1-9` |
| `sasl2-bin` | `2.1.28+dfsg1-9` | | `sasl2-bin` | `2.1.28+dfsg1-9` |
| `supervisor` | `4.2.5-3` | | `supervisor` | `4.2.5-3` |
+30 -7
View File
@@ -155,25 +155,48 @@ Two findings while writing it: a folded header reaches a milter
`-separated (the signer rebuilds CRLF), and go-milter calls no Abort on a `-separated (the signer rebuilds CRLF), and go-milter calls no Abort on a
dropped connection (a one-hour watchdog closes the signer). dropped connection (a one-hour watchdog closes the signer).
### Stage 2 - OpenDKIM removed (no release) ### Stage 2 - OpenDKIM removed (no release) - done
- [ ] Chain: OpenDKIM out of both milter lists. - [x] Chain: OpenDKIM out of both milter lists.
- [ ] Go: `internal/domain/opendkim.go` reduced to key storage (tables, - [x] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
`Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor` `Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor`
(its only caller is the reload), `internal/syslogrelay`, (its only caller is the reload), `internal/syslogrelay`,
`internal/milter/mailparse.go` and its tests and testdata, the `internal/milter/mailparse.go` and its tests and testdata, the
sender-check texts; Resync's "OpenDKIM's tables" step; Health's sender-check texts; Resync's "OpenDKIM's tables" step; Health's
liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`. liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`.
- [ ] Image: the `opendkim` package, `opendkim.conf`, the two supervisord - [x] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost
opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL` opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL`
capability (its only use was the reload). capability (its only use was the reload) - **kept, see below**.
- [ ] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint - [x] 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 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. the next release; KeyTable and SigningTable go with the new layout.
- [ ] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks, - [x] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks,
the 256-byte test rewritten for the new rule. the 256-byte test rewritten for the new rule.
*Done (2026-10-16):* `internal/domain/keys.go` (`Keys`, `NewKeys`,
`DKIM_DIR`) keeps the keys and writes a selector table,
`/data/dkim/selectors`, a line `<name> <selector>` each, which the signer
reads instead of the KeyTable: the signer reads no database, as the sender
check reads none - the panel's one SQLite connection is shared with the
pages, and a long query would hold up mail. The table is written before the
signer serves (`EnsureServerSigned`, moved there from the notifier), so the
first start of 2.8.0 needs nothing of the operator. Keys and table are
0600, directories 0700, owned by the panel; the entrypoint copies the old
keys as the panel user (`setpriv`), into `/data/dkim.new`, renamed when
whole, and stops the start if the copy fails - else the server's name would
get a new key. Three findings: `KILL` stays - measured on the test server,
root without it cannot signal a process of another uid, and supervisord
stops the panel and Postfix's master its children that way (the plan's
"only use" was wrong); the sender check's `Allowed` took the domain through
`strings.ToLower`, which makes the Kelvin sign a "k" - the comparison with
OpenDKIM's reading had hidden it, it now folds ASCII only; the Milter
sockets card told the spam filter by its row having a note, which the
signer's rows have too. The e2e drops the OpenDKIM-only cases
(`testNeverSignedForAnother`) and checks the one signature, the tree's
modes and the cited packages without opendkim, libopendkim11 and
libmilter1.0.1.
### Stage 3 - documents, the canary, the cut ### Stage 3 - documents, the canary, the cut
- [ ] architecture § OpenDKIM -> § DKIM signing; security.md (the - [ ] architecture § OpenDKIM -> § DKIM signing; security.md (the
+1 -1
View File
@@ -44,7 +44,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| 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) | | 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` | — | — | | 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` | — | — | | 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; stage 1 done) | 5/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) | | panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; stages 1-2 done) | 10/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) | — | — | | 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) | | 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) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// the old file back (fsync(2): syncing a file "does not necessarily ensure // 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 // 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 // disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
// OpenDKIM goes through here. // the DKIM signer goes through here.
func Write(path string, data []byte, perm os.FileMode) error { func Write(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*") tmp, err := os.CreateTemp(dir, ".tmp-*")
+1 -1
View File
@@ -16,7 +16,7 @@
// boot if the manifest's version does not match the running binary so // boot if the manifest's version does not match the running binary so
// schema/format skew between versions cannot silently corrupt state // schema/format skew between versions cannot silently corrupt state
// (architecture.md § Persistence), or if the archive was unpacked in part // (architecture.md § Persistence), or if the archive was unpacked in part
// (ContentsName), and a one-time Resync of OpenDKIM's tables // (ContentsName), and a one-time Resync of the DKIM selector table
// and the Postfix sender map from SQLite on that first boot, so any drift // 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 // 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 // on-disk state drifts again later — for example after a manual edit under
+3 -3
View File
@@ -43,7 +43,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
t.Fatalf("close store: %v", err) t.Fatalf("close store: %v", err)
} }
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY") writeFile(t, filepath.Join(dataDir, "dkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB") 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, "postfix", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n") writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n")
@@ -117,7 +117,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
for _, name := range []string{ for _, name := range []string{
DataArchivePrefix + ManifestName, DataArchivePrefix + ManifestName,
DataArchivePrefix + "selfpost.db", DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private", DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2", DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps", DataArchivePrefix + "postfix/sender_login_maps",
// The filter lists as the spam filter reads them. They are rewritten // The filter lists as the spam filter reads them. They are rewritten
@@ -452,7 +452,7 @@ func TestCreateLeavesTheQueueOut(t *testing.T) {
} }
for _, name := range []string{ for _, name := range []string{
DataArchivePrefix + "selfpost.db", DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private", DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2", DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps", DataArchivePrefix + "postfix/sender_login_maps",
ComposeFileName, EnvFileName, ComposeFileName, EnvFileName,
+1 -1
View File
@@ -5,7 +5,7 @@
// //
// The forbidden set stays with the caller rather than living here, because what // The forbidden set stays with the caller rather than living here, because what
// breaks a line differs per format — a comma separates values in a Postfix map, // breaks a line differs per format — a comma separates values in a Postfix map,
// a colon separates fields in an OpenDKIM table — and the set belongs next to // a slash would lead a key's path out of its directory — and the set belongs next to
// the writer it protects, where a reviewer of that writer can see it. // the writer it protects, where a reviewer of that writer can see it.
package configsafe package configsafe
+1 -1
View File
@@ -18,7 +18,7 @@ func TestToken(t *testing.T) {
} }
} }
// A character outside the caller's set is that format's business, not this // A character outside the caller's set is that format's business, not this
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash. // function's: a colon is refused for a key's name and fine in a texthash.
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil { if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
t.Errorf("character outside the forbidden set rejected: %v", err) t.Errorf("character outside the forbidden set rejected: %v", err)
} }
+19 -11
View File
@@ -24,22 +24,28 @@ func (c *countingApps) Resync() error {
// A deletion that fails after the domain's row is gone says the domain is // A deletion that fails after the domain's row is gone says the domain is
// deleted, and runs every rebuild anyway: the sender map was left naming the // deleted, and runs every rebuild anyway: the sender map was left naming the
// purged logins when OpenDKIM's tables failed first (audit 2026-10-12, L17). // purged logins when the signing tables failed first (audit 2026-10-12, L17).
// The key stays while the tables may still name it. // The key stays while the selector table may still name it.
func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) { func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
apps := &countingApps{} apps := &countingApps{}
s, odk := newTestService(t, apps) s, keys := newTestService(t, apps)
d, err := s.Add("gone.example") d, err := s.Add("gone.example")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
keyDir := filepath.Join(odk.keysDir, d.Name) keyDir := filepath.Join(keys.keysDir, d.Name)
apps.resyncs = 0 apps.resyncs = 0
odk.reload = func() error { return errors.New("supervisor away") } // A directory where the table goes: no file can be renamed over it.
if err := os.Remove(keys.selectorsPath); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(keys.selectorsPath, "x"), 0o700); err != nil {
t.Fatal(err)
}
err = s.Delete(d.ID) err = s.Delete(d.ID)
if !errors.Is(err, ErrDeletedIncomplete) { if !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with OpenDKIM's reload failing = %v, want ErrDeletedIncomplete", err) t.Fatalf("Delete with the selector table unwritable = %v, want ErrDeletedIncomplete", err)
} }
if _, err := s.Get(d.ID); !errors.Is(err, store.ErrDomainNotFound) { if _, err := s.Get(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
t.Errorf("the domain is still in the registry: %v", err) t.Errorf("the domain is still in the registry: %v", err)
@@ -53,7 +59,9 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
// Reload configuration finishes the deletion: the tables rebuilt, the // Reload configuration finishes the deletion: the tables rebuilt, the
// key of a domain that is gone goes too; the others' stay. // key of a domain that is gone goes too; the others' stay.
odk.reload = func() error { return nil } if err := os.RemoveAll(keys.selectorsPath); err != nil {
t.Fatal(err)
}
keep, err := s.Add("kept.example") keep, err := s.Add("kept.example")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -64,7 +72,7 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
if _, err := os.Stat(keyDir); !os.IsNotExist(err) { if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the deleted domain's key is still there after Resync: %v", err) t.Errorf("the deleted domain's key is still there after Resync: %v", err)
} }
if _, err := os.Stat(filepath.Join(odk.keysDir, keep.Name)); err != nil { if _, err := os.Stat(filepath.Join(keys.keysDir, keep.Name)); err != nil {
t.Errorf("Resync removed a domain's key: %v", err) t.Errorf("Resync removed a domain's key: %v", err)
} }
} }
@@ -73,16 +81,16 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
// rebuilt, so the key goes. // rebuilt, so the key goes.
func TestDeleteWithTheSenderMapFailing(t *testing.T) { func TestDeleteWithTheSenderMapFailing(t *testing.T) {
apps := &countingApps{resyncErr: errors.New("postfix away")} apps := &countingApps{resyncErr: errors.New("postfix away")}
s, odk := newTestService(t, apps) s, keys := newTestService(t, apps)
d, err := s.Add("gone.example") d, err := s.Add("gone.example")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
keyDir := filepath.Join(odk.keysDir, d.Name) keyDir := filepath.Join(keys.keysDir, d.Name)
if err := s.Delete(d.ID); !errors.Is(err, ErrDeletedIncomplete) { if err := s.Delete(d.ID); !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", err) t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", err)
} }
if _, err := os.Stat(keyDir); !os.IsNotExist(err) { if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the key is still there after the tables were rebuilt: %v", err) t.Errorf("the key is still there after the table was rebuilt: %v", err)
} }
} }
+10 -18
View File
@@ -1,8 +1,8 @@
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key // Package domain owns SelfPost's sending-domain model: per-domain DKIM key
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the // generation, the selector table the panel's signer reads, and the
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM // orchestration that keeps the SQLite registry and the on-disk keys in
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so // agreement (architecture.md § DKIM signing). Key material lives under /data
// it survives container restarts (architecture.md § OpenDKIM). // so it survives container restarts.
package domain package domain
import ( import (
@@ -32,23 +32,15 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
} }
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM, // writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
// atomically and group-readable (0640). The file is owned by the panel user and // atomically and readable by the panel user alone (0600): the panel's signer
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf // is the only reader (internal/milter, readKey()). The write is atomic (temp
// and entrypoint.sh); the parent directory carries setgid so the group is // file + rename) so the signer never reads a half-written key.
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
// a half-written key.
//
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
// (build/opendkim.conf).
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error { func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{ block := &pem.Block{
Type: "RSA PRIVATE KEY", Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key), Bytes: x509.MarshalPKCS1PrivateKey(key),
} }
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640) return writeFileAtomic(path, pem.EncodeToMemory(block), 0o600)
} }
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by // loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
@@ -79,7 +71,7 @@ type DKIMRecord struct {
} }
// dkimRecord builds the published DKIM DNS record for a public key. The value // dkimRecord builds the published DKIM DNS record for a public key. The value
// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key // is the one opendkim-genkey emitted: v=DKIM1, sha256, RSA, and the public key
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER. // as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) { func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
der, err := x509.MarshalPKIXPublicKey(pub) der, err := x509.MarshalPKIXPublicKey(pub)
@@ -95,7 +87,7 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
// writeFileAtomic writes data to path via a temp file in the same directory // writeFileAtomic 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. // followed by a rename, so readers only ever see the complete old or new file.
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables // It is the single safe-write primitive for DKIM keys and the selector table
// (security.md). // (security.md).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error { func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
return atomicfile.Write(path, data, perm) return atomicfile.Write(path, data, perm)
+2 -2
View File
@@ -21,8 +21,8 @@ func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("stat: %v", err) t.Fatalf("stat: %v", err)
} }
if perm := info.Mode().Perm(); perm != 0o640 { if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("key perm = %o, want 0640", perm) t.Errorf("key perm = %o, want 0600", perm)
} }
loaded, err := loadPrivateKeyPEM(path) loaded, err := loadPrivateKeyPEM(path)
+234
View File
@@ -0,0 +1,234 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/configsafe"
)
// Keys manages the DKIM state on disk (architecture.md § DKIM signing): a
// signing key per name under keysDir, keysDir/<name>/<selector>.private, and
// the selector table that says, for every name the server signs, which of its
// keys signs. The panel's signer (internal/milter, SignKeys) reads both; it
// reads no database, so a page's long query never holds up a message.
type Keys struct {
keysDir string
selectorsPath string
}
// NewKeys builds a manager rooted at dir (typically /data/dkim), the layout
// entrypoint.sh prepares.
func NewKeys(dir string) *Keys {
return &Keys{
keysDir: filepath.Join(dir, "keys"),
selectorsPath: filepath.Join(dir, "selectors"),
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// KeyPath is the private-key file of a name and selector.
func KeyPath(keysDir, domainName, selector string) string {
return filepath.Join(keysDir, domainName, selector+".private")
}
func (o *Keys) keyPath(domainName, selector string) string {
return KeyPath(o.keysDir, domainName, selector)
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (architecture.md § DKIM signing).
// Returns whether a new key was generated.
func (o *Keys) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (architecture.md § DKIM
// signing). A missing directory is not an error.
func (o *Keys) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// KeyDomains lists the names that have a key directory.
func (o *Keys) KeyDomains() ([]string, error) {
entries, err := os.ReadDir(o.keysDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list key dirs: %w", err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It
// re-marshals the parsed key rather than returning the raw file, so a
// malformed on-disk key is caught here.
func (o *Keys) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
// the domain with exactly this key, which is the whole point of keeping the
// published DNS record valid.
func (o *Keys) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (product.md).
func (o *Keys) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// Rebuild rewrites the selector table from the full domain set. Full
// regeneration (rather than incremental edits) keeps the file a pure function
// of the registry, so add and delete share one idempotent path. The signer
// reads the file again when it changes; nothing is reloaded.
func (o *Keys) Rebuild(domains []SigningDomain) error {
table, err := renderSelectors(domains)
if err != nil {
return err
}
return writeFileAtomic(o.selectorsPath, table, 0o600)
}
// signs reports whether the selector table signs name with the key of
// selector. A table that does not exist yet signs nothing. The selector is
// part of the question because the panel shows and checks the record of one
// key; a table that names another would have the signer sign with a key whose
// record nobody is told to publish.
func (o *Keys) signs(name, selector string) (bool, error) {
data, err := os.ReadFile(o.selectorsPath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", filepath.Base(o.selectorsPath), err)
}
for _, line := range strings.Split(string(data), "\n") {
if line == name+" "+selector {
return true, nil
}
}
return false, nil
}
// renderSelectors builds the selector table for a domain set, a line
// "<name> <selector>" each, sorted by name so the output is deterministic.
// Every domain is re-checked for config-injection safety before being written
// (security.md) — validation upstream already guarantees this, but the table
// writer refuses to emit anything unsafe as a hard backstop.
func renderSelectors(domains []SigningDomain) ([]byte, error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var sb strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, err
}
fmt.Fprintf(&sb, "%s %s\n", d.Name, d.Selector)
}
return []byte(sb.String()), nil
}
// keyForbidden is what may never appear in a name or a selector: white space
// and the newline that would end the selector table's field or line, and the
// path separators - the name and the selector are also the key's path - with
// the colon, which no DNS name holds either.
const keyForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line or out of the key directory. Domains are already
// whitelisted to [a-z0-9.-] and selectors to a similar set before they reach
// here (security.md); this is defence in depth against a validation gap ever
// letting whitespace, a newline or a path separator through. A name of dots
// alone - ".." - would also climb out of keysDir.
func assertConfigSafe(domainName, selector string) error {
for _, f := range []struct{ what, value string }{
{"domain", domainName},
{"selector", selector},
} {
if err := configsafe.Token(f.what, f.value, keyForbidden); err != nil {
return fmt.Errorf("dkim: %w", err)
}
if strings.Trim(f.value, ".") == "" {
return fmt.Errorf("dkim: unsafe %s %q", f.what, f.value)
}
}
return nil
}
@@ -3,41 +3,26 @@ package domain
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
func TestRenderTables(t *testing.T) { func TestRenderSelectors(t *testing.T) {
keysDir := "/data/opendkim/keys"
// Deliberately out of order to exercise the deterministic sort. // Deliberately out of order to exercise the deterministic sort.
domains := []SigningDomain{ domains := []SigningDomain{
{Name: "zeta.example", Selector: "selfpost"}, {Name: "zeta.example", Selector: "selfpost"},
{Name: "alpha.example", Selector: "sel2"}, {Name: "alpha.example", Selector: "sel2"},
} }
kt, st, err := renderTables(keysDir, domains) got, err := renderSelectors(domains)
if err != nil { if err != nil {
t.Fatalf("renderTables: %v", err) t.Fatalf("renderSelectors: %v", err)
} }
if want := "alpha.example sel2\nzeta.example selfpost\n"; string(got) != want {
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" + t.Errorf("selector table =\n%q\nwant\n%q", got, want)
"zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n"
if string(kt) != wantKT {
t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT)
} }
if empty, err := renderSelectors(nil); err != nil || len(empty) != 0 {
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n" t.Errorf("renderSelectors(nil) = %q, %v; want an empty table", empty, err)
if string(st) != wantST {
t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST)
}
}
func TestRenderTablesEmpty(t *testing.T) {
kt, st, err := renderTables("/keys", nil)
if err != nil {
t.Fatalf("renderTables(nil): %v", err)
}
if len(kt) != 0 || len(st) != 0 {
t.Errorf("expected empty tables, got kt=%q st=%q", kt, st)
} }
} }
@@ -49,6 +34,9 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
{"../etc", "selfpost"}, {"../etc", "selfpost"},
{"", "selfpost"}, {"", "selfpost"},
{"example.com", ""}, {"example.com", ""},
{"..", "selfpost"},
{".", "selfpost"},
{"example.com", ".."},
} }
for _, b := range bad { for _, b := range bad {
if err := assertConfigSafe(b.name, b.sel); err == nil { if err := assertConfigSafe(b.name, b.sel); err == nil {
@@ -60,21 +48,14 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
} }
} }
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out. // newTestKeys builds a manager rooted at a temp dir.
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) { func newTestKeys(t *testing.T) *Keys {
t.Helper() t.Helper()
dir := t.TempDir() return NewKeys(t.TempDir())
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil {
t.Fatal(err)
}
reloads := 0
o := NewOpenDKIM(dir)
o.reload = func() error { reloads++; return nil }
return o, &reloads
} }
func TestEnsureKeyReusesExisting(t *testing.T) { func TestEnsureKeyReusesExisting(t *testing.T) {
o, _ := newTestOpenDKIM(t) o := newTestKeys(t)
created, err := o.EnsureKey("example.com", "selfpost") created, err := o.EnsureKey("example.com", "selfpost")
if err != nil { if err != nil {
@@ -101,28 +82,41 @@ func TestEnsureKeyReusesExisting(t *testing.T) {
} }
} }
func TestRebuildWritesTablesAndReloads(t *testing.T) { // The key and the table are the panel's alone: no other program reads them.
o, reloads := newTestOpenDKIM(t) func TestRebuildWritesThePanelsOwnFiles(t *testing.T) {
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil { if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
t.Fatalf("Rebuild: %v", err) t.Fatalf("Rebuild: %v", err)
} }
if *reloads != 1 { if signed, err := o.signs("example.com", "selfpost"); err != nil || !signed {
t.Errorf("reload called %d times, want 1", *reloads) t.Errorf("signs after Rebuild = %v, %v", signed, err)
} }
kt, err := os.ReadFile(o.keyTablePath) if signed, err := o.signs("example.com", "other"); err != nil || signed {
if err != nil { t.Errorf("signs with another selector = %v, %v", signed, err)
t.Fatal(err)
} }
if !strings.Contains(string(kt), "example.com:selfpost:") { if runtime.GOOS == "windows" {
t.Errorf("KeyTable missing entry: %q", kt) return // no Unix modes
}
for path, want := range map[string]os.FileMode{
o.selectorsPath: 0o600,
o.keyPath("example.com", "selfpost"): 0o600,
filepath.Join(o.keysDir, "example.com"): 0o700,
} {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != want {
t.Errorf("%s: mode %o, want %o", path, got, want)
}
} }
} }
func TestRemoveKey(t *testing.T) { func TestRemoveKey(t *testing.T) {
o, _ := newTestOpenDKIM(t) o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -139,7 +133,7 @@ func TestRemoveKey(t *testing.T) {
} }
func TestRecordFromWrittenKey(t *testing.T) { func TestRecordFromWrittenKey(t *testing.T) {
o, _ := newTestOpenDKIM(t) o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
-312
View File
@@ -1,312 +0,0 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/supervisor"
)
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the
// KeyTable/SigningTable that map domains to those keys. After rewriting the
// tables it asks OpenDKIM to reload them.
type OpenDKIM struct {
keysDir string
keyTablePath string
signingTablePath string
// reload sends the running OpenDKIM a reload signal. It is a field so tests
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
reload func() error
}
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
// through supervisord.
func NewOpenDKIM(dir string) *OpenDKIM {
return &OpenDKIM{
keysDir: filepath.Join(dir, "keys"),
keyTablePath: filepath.Join(dir, "KeyTable"),
signingTablePath: filepath.Join(dir, "SigningTable"),
reload: reloadViaSupervisor,
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
func (o *OpenDKIM) keyPath(domainName, selector string) string {
return filepath.Join(o.keysDir, domainName, selector+".private")
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (architecture.md § OpenDKIM).
// Returns whether a new key was generated.
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
// shared `selfpost` group so OpenDKIM can traverse into it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A
// missing directory is not an error.
func (o *OpenDKIM) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// KeyDomains lists the names that have a key directory.
func (o *OpenDKIM) KeyDomains() ([]string, error) {
entries, err := os.ReadDir(o.keysDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list key dirs: %w", err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It
// re-marshals the parsed key rather than returning the raw file, so a
// malformed on-disk key is caught here.
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
// the domain with exactly this key, which is the whole point of keeping the
// published DNS record valid.
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (product.md).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// SigningTablePath is the SigningTable's location: the names this server
// signs for, which the sender check reads (internal/milter/sender.go).
func (o *OpenDKIM) SigningTablePath() string {
return o.signingTablePath
}
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather
// than incremental edits) keeps the files a pure function of the registry, so
// add and delete share one idempotent path. Both files are written atomically
// before the reload signal is sent.
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
keyTable, signingTable, err := renderTables(o.keysDir, domains)
if err != nil {
return err
}
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
return err
}
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
return err
}
return o.reload()
}
// signs reports whether the tables on disk sign name with the key of
// selector: a line for the name in the SigningTable, and in the KeyTable the
// line that gives it that selector. Tables that do not exist yet sign nothing.
// The selector is part of the question because the panel shows and checks the
// record of one key; tables that name another would have OpenDKIM sign with a
// key whose record nobody is told to publish.
//
// OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is
// matched as ^.*@name$ (case aside), so it signs name and none of its
// subdomains (see Signers.Signs in internal/milter/sender.go).
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
has := func(path, prefix string) (bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("read %s: %w", filepath.Base(path), err)
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, prefix) {
return true, nil
}
}
return false, nil
}
signed, err := has(o.signingTablePath, "*@"+name+" ")
if err != nil || !signed {
return false, err
}
return has(o.keyTablePath, name+" "+name+":"+selector+":")
}
// Reload asks OpenDKIM to re-read its tables without regenerating them. It
// backs the panel's manual reload button (architecture.md § Panel HTTP
// surface).
func (o *OpenDKIM) Reload() error {
return o.reload()
}
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (o *OpenDKIM) SetReloadHook(fn func() error) {
if fn != nil {
o.reload = fn
}
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written
// (security.md) — validation upstream already guarantees this, but the table
// writer refuses to emit anything unsafe as a hard backstop.
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
// OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the
// first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE);
// opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and
// key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()),
// a key starting with "/" being a file. SigningTable is refile:, a pattern
// up to the first blank and the key name after it (dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE).
var kt, st strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, nil, err
}
keyName := d.Name // one key per domain; the domain name is a fine handle
// Absolute key path so OpenDKIM resolves it independently of its CWD.
keyFile := path.Join(keysDir, d.Name, d.Selector+".private")
// KeyTable: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name>
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
}
return []byte(kt.String()), []byte(st.String()), nil
}
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
// value: whitespace and the newline that would end the line, and the colon and
// path separators that carry meaning inside a table entry. (OpenDKIM
// 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a
// KeyTable value at ":"; the domain and selector are also the key's path.)
const opendkimForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (security.md); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (security.md).
func assertConfigSafe(domainName, selector string) error {
for _, f := range []struct{ what, value string }{
{"domain", domainName},
{"selector", selector},
} {
if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
return fmt.Errorf("opendkim: %w", err)
}
}
return nil
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
// signal another user's process directly, so it goes through the supervisor
// control socket, reachable via the shared `selfpost` group (security.md).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md).
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets
// reload when a configuration file was given (-x in build/supervisord.conf);
// dkimf_config_reload(), from mlfi_negotiate() at the next milter connection,
// loads the configuration and opens the tables again (opendkim.8, OPERATION).
// supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and
// process.py, signal(): kill() of the program's own pid - opendkim itself, run
// with -f (not forked into the background) and exec'd with no shell.
func reloadViaSupervisor() error {
return supervisor.Signal("USR1", "opendkim")
}
+34 -50
View File
@@ -6,37 +6,27 @@ import (
"testing" "testing"
) )
func readTables(t *testing.T, odk *OpenDKIM) (keyTable, signingTable string) { func readSelectors(t *testing.T, keys *Keys) string {
t.Helper() t.Helper()
kt, err := os.ReadFile(odk.keyTablePath) data, err := os.ReadFile(keys.selectorsPath)
if err != nil { if err != nil {
t.Fatalf("read KeyTable: %v", err) t.Fatalf("read the selector table: %v", err)
} }
st, err := os.ReadFile(odk.signingTablePath) return string(data)
if err != nil {
t.Fatalf("read SigningTable: %v", err)
}
return string(kt), string(st)
} }
// The server's own name is signed like a sending domain - a key, a line in // The server's own name is signed like a sending domain - a key, a line in
// each table - without being one: no row in the registry, so no application // the selector table - without being one: no row in the registry, so no
// can send as it. // application can send as it.
func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) { func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{}) svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org") svc.SetServerName("mail.example.org")
if _, err := svc.Add("example.com"); err != nil { if _, err := svc.Add("example.com"); err != nil {
t.Fatalf("add domain: %v", err) t.Fatalf("add domain: %v", err)
} }
kt, st := readTables(t, odk) if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
for _, want := range []string{"mail.example.org mail.example.org:selfpost:", "example.com example.com:selfpost:"} { t.Errorf("selector table =\n%q\nwant\n%q", got, want)
if !strings.Contains(kt, want) {
t.Errorf("KeyTable lacks %q:\n%s", want, kt)
}
}
if !strings.Contains(st, "*@mail.example.org mail.example.org\n") {
t.Errorf("SigningTable does not sign the server's own name:\n%s", st)
} }
domains, err := svc.List() domains, err := svc.List()
if err != nil || len(domains) != 1 { if err != nil || len(domains) != 1 {
@@ -61,16 +51,15 @@ func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
// When the hostname is itself a sending domain, there is one key and one line, // When the hostname is itself a sending domain, there is one key and one line,
// the domain's; deleting the domain leaves the server's mail signed with it. // the domain's; deleting the domain leaves the server's mail signed with it.
func TestServerNameThatIsAlsoADomain(t *testing.T) { func TestServerNameThatIsAlsoADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{}) svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("example.com") svc.SetServerName("example.com")
d, err := svc.Add("example.com") d, err := svc.Add("example.com")
if err != nil { if err != nil {
t.Fatalf("add domain: %v", err) t.Fatalf("add domain: %v", err)
} }
_, st := readTables(t, odk) if got := readSelectors(t, keys); got != "example.com selfpost\n" {
if strings.Count(st, "*@example.com ") != 1 { t.Errorf("selector table = %q, want the name once", got)
t.Errorf("the name is in the SigningTable more than once:\n%s", st)
} }
asDomain, err := svc.DKIMRecord(d) asDomain, err := svc.DKIMRecord(d)
if err != nil { if err != nil {
@@ -84,9 +73,8 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
if err := svc.Delete(d.ID); err != nil { if err := svc.Delete(d.ID); err != nil {
t.Fatalf("delete domain: %v", err) t.Fatalf("delete domain: %v", err)
} }
_, st = readTables(t, odk) if got := readSelectors(t, keys); got != "example.com selfpost\n" {
if !strings.Contains(st, "*@example.com example.com\n") { t.Errorf("deleting the domain stopped the server's own mail being signed: %q", got)
t.Errorf("deleting the domain stopped the server's own mail being signed:\n%s", st)
} }
after, err := svc.ServerMail() after, err := svc.ServerMail()
if err != nil || after.Record.Value != asDomain.Value { if err != nil || after.Record.Value != asDomain.Value {
@@ -94,48 +82,45 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
} }
} }
// An installation upgraded to a version that signs the server's own name has // The first start of 2.8.0 finds no selector table - OpenDKIM's tables were
// tables without its line until something rebuilds them. The start does, once. // the earlier versions' - and an installation may lack the server's line.
// The start rebuilds, once.
func TestEnsureServerSignedRebuildsOnce(t *testing.T) { func TestEnsureServerSignedRebuildsOnce(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{}) svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil { if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
reloads := 0 if err := os.Remove(keys.selectorsPath); err != nil {
odk.reload = func() error { reloads++; return nil } t.Fatal(err)
}
// The tables as the earlier version left them; now the name is known.
svc.SetServerName("mail.example.org") svc.SetServerName("mail.example.org")
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt { if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
t.Fatalf("first start: rebuilt = %v, %v; want a rebuild", rebuilt, err) t.Fatalf("first start: rebuilt = %v, %v; want a rebuild", rebuilt, err)
} }
if _, st := readTables(t, odk); !strings.Contains(st, "*@mail.example.org mail.example.org\n") || !strings.Contains(st, "*@example.com ") { if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
t.Errorf("SigningTable after the rebuild:\n%s", st) t.Errorf("selector table after the rebuild = %q, want %q", got, want)
} }
if rebuilt, err := svc.EnsureServerSigned(); err != nil || rebuilt { if rebuilt, err := svc.EnsureServerSigned(); err != nil || rebuilt {
t.Errorf("second start: rebuilt = %v, %v; want nothing to do", rebuilt, err) t.Errorf("second start: rebuilt = %v, %v; want nothing to do", rebuilt, err)
} }
if reloads != 1 {
t.Errorf("OpenDKIM was reloaded %d times, want once", reloads)
}
// A name that only begins like the server's is not it. // A name that only begins like the server's is not it.
other, otherOdk := newTestService(t, &fakeApps{}) other, otherKeys := newTestService(t, &fakeApps{})
other.SetServerName("mail.example.org.evil.example") other.SetServerName("mail.example.org.evil.example")
if _, err := other.EnsureServerSigned(); err != nil { if _, err := other.EnsureServerSigned(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
other.SetServerName("mail.example.org") if signed, err := otherKeys.signs("mail.example.org", "selfpost"); err != nil || signed {
if signed, err := otherOdk.signs("mail.example.org", "selfpost"); err != nil || signed {
t.Errorf("a longer name was taken for the server's: %v, %v", signed, err) t.Errorf("a longer name was taken for the server's: %v, %v", signed, err)
} }
} }
// Tables that sign the server's name with the key of another selector - the // A table that signs the server's name with the key of another selector - the
// default selector was changed - are rebuilt: OpenDKIM must sign with the key // default selector was changed - is rebuilt: the signer must sign with the key
// whose record the panel shows. // whose record the panel shows.
func TestEnsureServerSignedFollowsTheSelector(t *testing.T) { func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{}) svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org") svc.SetServerName("mail.example.org")
if _, err := svc.EnsureServerSigned(); err != nil { if _, err := svc.EnsureServerSigned(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -145,24 +130,23 @@ func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt { if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
t.Fatalf("after the selector changed: rebuilt = %v, %v; want a rebuild", rebuilt, err) t.Fatalf("after the selector changed: rebuilt = %v, %v; want a rebuild", rebuilt, err)
} }
kt, _ := readTables(t, odk) if got := readSelectors(t, keys); got != "mail.example.org sp2027\n" {
if !strings.Contains(kt, "mail.example.org mail.example.org:sp2027:") || strings.Contains(kt, ":selfpost:") { t.Errorf("selector table after the rebuild = %q", got)
t.Errorf("KeyTable after the rebuild:\n%s", kt)
} }
mail, err := svc.ServerMail() mail, err := svc.ServerMail()
if err != nil || mail.Selector != "sp2027" { if err != nil || mail.Selector != "sp2027" {
t.Errorf("the panel shows selector %q (%v), the tables sign with sp2027", mail.Selector, err) t.Errorf("the panel shows selector %q (%v), the table signs with sp2027", mail.Selector, err)
} }
} }
// Without a hostname nothing is added and nothing is signed for it. // Without a hostname nothing is added and nothing is signed for it.
func TestNoServerName(t *testing.T) { func TestNoServerName(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{}) svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil { if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, st := readTables(t, odk); strings.Count(st, "\n") != 1 { if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("SigningTable has more than the one domain:\n%s", st) t.Errorf("selector table has more than the one domain: %q", got)
} }
if _, err := svc.ServerMail(); err == nil { if _, err := svc.ServerMail(); err == nil {
t.Error("ServerMail without a hostname returned no error") t.Error("ServerMail without a hostname returned no error")
+45 -46
View File
@@ -29,14 +29,14 @@ type Applications interface {
} }
// Service coordinates the places a sending domain lives: the SQLite registry, // Service coordinates the places a sending domain lives: the SQLite registry,
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL // the on-disk DKIM keys and the selector table the signer reads, plus — on
// database and Postfix sender map its applications touch. Callers (the web // deletion — the SASL database and Postfix sender map its applications touch.
// handlers) validate user input first; Service keeps the stores in agreement // Callers (the web handlers) validate user input first; Service keeps the
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM, // stores in agreement and drives the Postfix reloads (architecture.md § DKIM
// product.md). // signing, product.md).
type Service struct { type Service struct {
store *store.Store store *store.Store
odk *OpenDKIM keys *Keys
apps Applications apps Applications
selector string selector string
// serverName is the server's own hostname, which the panel's notifications // serverName is the server's own hostname, which the panel's notifications
@@ -48,7 +48,7 @@ type Service struct {
// SetServerName gives the service the server's own hostname. The panel sends // SetServerName gives the service the server's own hostname. The panel sends
// its notifications from postmaster@ that name, and for a receiver to believe // its notifications from postmaster@ that name, and for a receiver to believe
// them the name is signed like a sending domain: it gets a DKIM key and a line // them the name is signed like a sending domain: it gets a DKIM key and a line
// in OpenDKIM's tables, without being a row of the registry - no application // in the selector table, without being a row of the registry - no application
// can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint // can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint
// has validated; it is not user input. // has validated; it is not user input.
func (s *Service) SetServerName(hostname string) { func (s *Service) SetServerName(hostname string) {
@@ -74,23 +74,23 @@ func (s *Service) ServerMail() (ServerMail, error) {
if err != nil { if err != nil {
return ServerMail{}, err return ServerMail{}, err
} }
if _, err := s.odk.EnsureKey(s.serverName, selector); err != nil { if _, err := s.keys.EnsureKey(s.serverName, selector); err != nil {
return ServerMail{}, err return ServerMail{}, err
} }
rec, err := s.odk.Record(s.serverName, selector) rec, err := s.keys.Record(s.serverName, selector)
if err != nil { if err != nil {
return ServerMail{}, err return ServerMail{}, err
} }
return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil
} }
// EnsureServerSigned makes sure OpenDKIM's tables have the line for the // EnsureServerSigned makes sure the selector table has the line for the
// server's own name, rebuilding them if they do not. The tables are otherwise // server's own name, rebuilding it if it does not - or if there is no table
// rebuilt only when a domain is added or deleted, so an installation that was // yet, as on the first start of 2.8.0, whose table replaced OpenDKIM's. The
// upgraded to a version with notifications would go on sending them unsigned // table is otherwise rebuilt only when a domain is added or deleted, so an
// until then, and one whose DKIM_SELECTOR_DEFAULT was changed would go on // installation whose DKIM_SELECTOR_DEFAULT was changed would go on signing
// signing with the key of the old selector while the panel shows the record // with the key of the old selector while the panel shows the record of the
// of the new. It reports whether it had to rebuild. // new. It reports whether it had to rebuild.
func (s *Service) EnsureServerSigned() (bool, error) { func (s *Service) EnsureServerSigned() (bool, error) {
if s.serverName == "" { if s.serverName == "" {
return false, nil return false, nil
@@ -99,7 +99,7 @@ func (s *Service) EnsureServerSigned() (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
signed, err := s.odk.signs(s.serverName, selector) signed, err := s.keys.signs(s.serverName, selector)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -129,8 +129,8 @@ func (s *Service) serverSelector() (string, error) {
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is // DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
// used only on deletion, to clear the SASL accounts and sender-map bindings of // used only on deletion, to clear the SASL accounts and sender-map bindings of
// the domain's applications. // the domain's applications.
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service { func NewService(st *store.Store, keys *Keys, apps Applications, selectorDefault string) *Service {
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault} return &Service{store: st, keys: keys, apps: apps, selector: selectorDefault}
} }
// List returns all domains with application counts (product.md). // List returns all domains with application counts (product.md).
@@ -144,7 +144,7 @@ func (s *Service) Get(id int64) (store.Domain, error) {
} }
// Add registers a new sending domain: it records the row, ensures a DKIM key // Add registers a new sending domain: it records the row, ensures a DKIM key
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md). // exists on disk, and regenerates the selector table (product.md).
// name must already be normalised and validated by the caller. A duplicate // name must already be normalised and validated by the caller. A duplicate
// returns store.ErrDomainExists. // returns store.ErrDomainExists.
// //
@@ -152,15 +152,15 @@ func (s *Service) Get(id int64) (store.Domain, error) {
// arbiter of "already exists" (avoiding a check-then-act race). An existing // arbiter of "already exists" (avoiding a check-then-act race). An existing
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB // on-disk key is reused rather than overwritten, so re-adding a domain whose DB
// row was lost keeps its published DNS record valid. If key generation or the // row was lost keeps its published DNS record valid. If key generation or the
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered // table's rebuild fails, the row is rolled back so we never leave a registered
// domain that OpenDKIM cannot sign. // domain the signer cannot sign for.
func (s *Service) Add(name string) (store.Domain, error) { func (s *Service) Add(name string) (store.Domain, error) {
d, err := s.store.AddDomain(name, s.selector) d, err := s.store.AddDomain(name, s.selector)
if err != nil { if err != nil {
return store.Domain{}, err return store.Domain{}, err
} }
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil { if _, err := s.keys.EnsureKey(d.Name, d.DKIMSelector); err != nil {
s.rollbackAdd(d.ID) s.rollbackAdd(d.ID)
return store.Domain{}, err return store.Domain{}, err
} }
@@ -181,10 +181,10 @@ func (s *Service) rollbackAdd(id int64) {
// Delete removes a domain and everything bound to it (product.md). The // Delete removes a domain and everything bound to it (product.md). The
// order matters: the applications' SASL accounts are cleared first, while their // order matters: the applications' SASL accounts are cleared first, while their
// logins are still in the registry; then the registry rows (applications and // logins are still in the registry; then the registry rows (applications and
// their addresses) go via the DB cascade; then the OpenDKIM tables and the // their addresses) go via the DB cascade; then the selector table and the
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing // Postfix sender map are rebuilt from what remains — so the signer stops
// and Postfix stops authorising the domain's senders — before the DKIM key is // signing and Postfix stops authorising the domain's senders — before the
// deleted. // DKIM key is deleted.
func (s *Service) Delete(id int64) error { func (s *Service) Delete(id int64) error {
d, err := s.store.GetDomain(id) d, err := s.store.GetDomain(id)
if err != nil { if err != nil {
@@ -204,26 +204,26 @@ func (s *Service) Delete(id int64) error {
return err return err
} }
// From here the domain is deleted, whatever fails after: each rebuild // From here the domain is deleted, whatever fails after: each rebuild
// runs - the sender map was left with the purged logins when OpenDKIM's // runs - the sender map was left with the purged logins when the
// failed first - and a failure says the domain is gone (audit // signing tables failed first - and a failure says the domain is gone
// 2026-10-12, L17). // (audit 2026-10-12, L17).
var errs []error var errs []error
tablesRebuilt := true tablesRebuilt := true
if err := s.resync(); err != nil { if err := s.resync(); err != nil {
tablesRebuilt = false tablesRebuilt = false
errs = append(errs, fmt.Errorf("rebuild OpenDKIM's tables: %w", err)) errs = append(errs, fmt.Errorf("rebuild the selector table: %w", err))
} }
if err := s.apps.Resync(); err != nil { if err := s.apps.Resync(); err != nil {
errs = append(errs, fmt.Errorf("rebuild the sender map: %w", err)) errs = append(errs, fmt.Errorf("rebuild the sender map: %w", err))
} }
// A sending domain named like the server shared its key with the server's // A sending domain named like the server shared its key with the server's
// own mail. The resync above kept the name in the tables under the default // own mail. The resync above kept the name in the table under the default
// selector: the same key if that was the domain's selector, a new one - // selector: the same key if that was the domain's selector, a new one -
// with a new record to publish, which Settings shows - if it was not. // with a new record to publish, which Settings shows - if it was not.
// Either way the directory holds the key now in use. And while the tables // Either way the directory holds the key now in use. And while the table
// may still name the deleted domain's key, the key stays. // may still name the deleted domain's key, the key stays.
if d.Name != s.serverName && tablesRebuilt { if d.Name != s.serverName && tablesRebuilt {
if err := s.odk.RemoveKey(d.Name); err != nil { if err := s.keys.RemoveKey(d.Name); err != nil {
errs = append(errs, fmt.Errorf("remove its DKIM key: %w", err)) errs = append(errs, fmt.Errorf("remove its DKIM key: %w", err))
} }
} }
@@ -240,7 +240,7 @@ var ErrDeletedIncomplete = errors.New("domain deleted, but the mail configuratio
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md). // DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) { func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector) return s.keys.Record(d.Name, d.DKIMSelector)
} }
// RateLimit returns the domain-level differentiated rate limit (guide § Rate // RateLimit returns the domain-level differentiated rate limit (guide § Rate
@@ -263,13 +263,12 @@ func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID) return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
} }
// Resync regenerates the OpenDKIM tables from the registry and reloads // Resync regenerates the selector table from the registry. It backs the
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP // manual reload button (architecture.md § Panel HTTP surface) and doubles as
// surface) and doubles as a recovery path if the tables ever drift from the // a recovery path if the table ever drifts from the database.
// database.
// //
// It also removes the key directories of names no domain has and that are // It also removes the key directories of names no domain has and that are
// not the server's own: what a deletion left when the tables could not be // not the server's own: what a deletion left when the table could not be
// rebuilt (Delete keeps the key then). The "Reload configuration" the page // rebuilt (Delete keeps the key then). The "Reload configuration" the page
// points to after such a deletion finishes it here (review of audit stage 4). // points to after such a deletion finishes it here (review of audit stage 4).
func (s *Service) Resync() error { func (s *Service) Resync() error {
@@ -284,13 +283,13 @@ func (s *Service) Resync() error {
for _, d := range domains { for _, d := range domains {
keep[d.Name] = true keep[d.Name] = true
} }
names, err := s.odk.KeyDomains() names, err := s.keys.KeyDomains()
if err != nil { if err != nil {
return err return err
} }
for _, name := range names { for _, name := range names {
if !keep[name] { if !keep[name] {
if err := s.odk.RemoveKey(name); err != nil { if err := s.keys.RemoveKey(name); err != nil {
return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err) return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err)
} }
} }
@@ -298,7 +297,7 @@ func (s *Service) Resync() error {
return nil return nil
} }
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads. // resync rebuilds the selector table from the current domain set.
func (s *Service) resync() error { func (s *Service) resync() error {
domains, err := s.store.ListDomains() domains, err := s.store.ListDomains()
if err != nil { if err != nil {
@@ -313,12 +312,12 @@ func (s *Service) resync() error {
} }
} }
// The server's own name, for the panel's notifications - unless it is a // The server's own name, for the panel's notifications - unless it is a
// sending domain already and so in the tables above. // sending domain already and so in the table above.
if s.serverName != "" && !registered { if s.serverName != "" && !registered {
if _, err := s.odk.EnsureKey(s.serverName, s.selector); err != nil { if _, err := s.keys.EnsureKey(s.serverName, s.selector); err != nil {
return fmt.Errorf("dkim key for %s: %w", s.serverName, err) return fmt.Errorf("dkim key for %s: %w", s.serverName, err)
} }
signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector}) signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector})
} }
return s.odk.Rebuild(signing) return s.keys.Rebuild(signing)
} }
+3 -3
View File
@@ -57,7 +57,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
if err != nil { if err != nil {
return DomainExport{}, err return DomainExport{}, err
} }
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector) pem, err := s.keys.ExportKey(d.Name, d.DKIMSelector)
if err != nil { if err != nil {
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err) return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
} }
@@ -109,7 +109,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
// Import re-creates a domain from an export file on this instance // Import re-creates a domain from an export file on this instance
// (architecture.md § Persistence): it stores the imported DKIM key (so the // (architecture.md § Persistence): it stores the imported DKIM key (so the
// published DNS record needs no change), registers the domain and rebuilds the // published DNS record needs no change), registers the domain and rebuilds the
// OpenDKIM tables, then re-creates each application with its working password // selector table, then re-creates each application with its working password
// and rebuilds the Postfix sender map. // and rebuilds the Postfix sender map.
// //
// exp.Domain must already be normalised and validated by the caller // exp.Domain must already be normalised and validated by the caller
@@ -133,7 +133,7 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
return store.Domain{}, err // ErrDomainExists surfaces to the caller return store.Domain{}, err // ErrDomainExists surfaces to the caller
} }
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil { if err := s.keys.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, err return store.Domain{}, err
} }
+5 -6
View File
@@ -43,18 +43,17 @@ func (f *fakeApps) ImportApplication(domainID int64, login, mode string, address
return nil return nil
} }
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in // newTestService builds a Service over a fresh SQLite store and key tree in a
// a temp dir, with the OpenDKIM reload signal stubbed out. // temp dir.
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) { func newTestService(t *testing.T, apps Applications) (*Service, *Keys) {
t.Helper() t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db") st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open store: %v", err)
} }
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
odk := NewOpenDKIM(t.TempDir()) keys := NewKeys(t.TempDir())
odk.reload = func() error { return nil } return NewService(st, keys, apps, "selfpost"), keys
return NewService(st, odk, apps, "selfpost"), odk
} }
func TestExportImportRoundTrip(t *testing.T) { func TestExportImportRoundTrip(t *testing.T) {
+6 -8
View File
@@ -116,14 +116,14 @@ func TestCheckCertificate(t *testing.T) {
func TestCheckSocket(t *testing.T) { func TestCheckSocket(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sock := filepath.Join(dir, "opendkim.sock") sock := filepath.Join(dir, "sign.sock")
l, err := net.Listen("unix", sock) l, err := net.Listen("unix", sock)
if err != nil { if err != nil {
t.Skipf("unix sockets unavailable here: %v", err) t.Skipf("unix sockets unavailable here: %v", err)
} }
defer l.Close() defer l.Close()
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present { if got := CheckSocket("dkim-signer", sock, true); got.Status != StatusOK || !got.Present {
t.Errorf("live socket: status %q present=%v", got.Status, got.Present) t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
} }
@@ -131,7 +131,7 @@ func TestCheckSocket(t *testing.T) {
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn { if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
t.Errorf("missing optional socket: status %q", got.Status) t.Errorf("missing optional socket: status %q", got.Status)
} }
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError { if got := CheckSocket("dkim-signer", missing, true); got.Status != StatusError {
t.Errorf("missing required socket: status %q", got.Status) t.Errorf("missing required socket: status %q", got.Status)
} }
@@ -139,7 +139,7 @@ func TestCheckSocket(t *testing.T) {
if err := os.WriteFile(plain, nil, 0o600); err != nil { if err := os.WriteFile(plain, nil, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present { if got := CheckSocket("dkim-signer", plain, true); got.Status != StatusError || got.Present {
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present) t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
} }
} }
@@ -170,8 +170,7 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
func TestLivenessFromParsedProcesses(t *testing.T) { func TestLivenessFromParsedProcesses(t *testing.T) {
// Composed, in the columns of TestParseProcesses. // Composed, in the columns of TestParseProcesses.
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10 allRunning := `panel RUNNING pid 22, uptime 0:04:09
panel RUNNING pid 22, uptime 0:04:09
postfix RUNNING pid 23, uptime 0:04:08 postfix RUNNING pid 23, uptime 0:04:08
postfix-reload STOPPED Not started postfix-reload STOPPED Not started
` `
@@ -182,8 +181,7 @@ postfix-reload STOPPED Not started
} }
} }
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10 postfixDead := `panel RUNNING pid 22, uptime 0:04:09
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly postfix FATAL Exited too quickly
` `
procs = parseProcesses(postfixDead) procs = parseProcesses(postfixDead)
+4 -5
View File
@@ -8,14 +8,13 @@ import (
// mailPathPrograms are the supervised processes whose absence means the // mailPathPrograms are the supervised processes whose absence means the
// container should not report healthy to orchestrators. // container should not report healthy to orchestrators.
var mailPathPrograms = map[string]bool{ var mailPathPrograms = map[string]bool{
"opendkim": true, "panel": true,
"panel": true, "postfix": true,
"postfix": true,
} }
// Liveness reports whether the mail path is healthy enough for container // Liveness reports whether the mail path is healthy enough for container
// probes. It requires opendkim, panel, and postfix to be RUNNING under // probes. It requires panel - whose roles include the DKIM signer - and
// supervisord. // postfix to be RUNNING under supervisord.
func Liveness() error { func Liveness() error {
procs, err := Processes() procs, err := Processes()
if err != nil { if err != nil {
+3 -3
View File
@@ -146,7 +146,7 @@ func TestCheckMilterInet6(t *testing.T) {
} }
} }
// A socket file whose listener is gone - a killed OpenDKIM or panel leaves // A socket file whose listener is gone - a killed panel leaves
// one (unix(7)) - is not a listener, though the file is there (A-38). // one (unix(7)) - is not a listener, though the file is there (A-38).
func TestStaleSocketFileIsNotListening(t *testing.T) { func TestStaleSocketFileIsNotListening(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -163,8 +163,8 @@ func TestStaleSocketFileIsNotListening(t *testing.T) {
t.Fatalf("the socket file should outlive its listener: %v", err) t.Fatalf("the socket file should outlive its listener: %v", err)
} }
got := CheckSocket("OpenDKIM", sock, true) got := CheckSocket("dkim-signer", sock, true)
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the OpenDKIM socket") { if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the dkim-signer socket") {
t.Errorf("stale required socket: %+v", got) t.Errorf("stale required socket: %+v", got)
} }
if got := CheckSocket("send-log", sock, false); got.Status != StatusWarn { if got := CheckSocket("send-log", sock, false); got.Status != StatusWarn {
+7 -10
View File
@@ -21,9 +21,10 @@ type Socket struct {
Detail string Detail string
} }
// CheckSocket checks a milter socket. required distinguishes the two milters: // CheckSocket checks a milter socket. required distinguishes the milters:
// Postfix attaches OpenDKIM with default_action=tempfail, so a missing socket // Postfix attaches the DKIM signer and the sender check with
// stops mail leaving the server, while the journal-milter is attached with // default_action=tempfail, so a missing socket stops mail leaving the server,
// while the journal-milter is attached with
// default_action=accept and fails open — mail still goes out, only the send // default_action=accept and fails open — mail still goes out, only the send
// log stops being written (build/postfix-config.sh smtpd_milters; Postfix // log stops being written (build/postfix-config.sh smtpd_milters; Postfix
// 3.10.13, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451 // 3.10.13, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
@@ -32,13 +33,9 @@ type Socket struct {
// The socket is dialled, as Postfix will dial it; a file is not a listener. // The socket is dialled, as Postfix will dial it; a file is not a listener.
// A unix socket's file outlives the process that bound it unless that process // A unix socket's file outlives the process that bound it unless that process
// unlinks it, and a connect() to it then fails with ECONNREFUSED (unix(7)), so // unlinks it, and a connect() to it then fails with ECONNREFUSED (unix(7)), so
// a killed OpenDKIM or panel leaves a file that refuses every connection - // a killed panel leaves a file that refuses every connection - assumptions
// assumptions A-38. The connection is closed before a byte is sent, which // A-38. The connection is closed before a byte is sent, which none of the
// none of the three milters logs: OpenDKIM's libmilter reads the EOF as // panel's milters logs: go-milter v0.4.1 returns quietly on io.EOF
// SMFIC_EOF and ends the session without a line (libmilter 8.18.1,
// libmilter/comm.c, mi_rd_cmd(); engine.c, mi_engine()), and OpenDKIM's
// mlfi_close() has no connection to clean up (opendkim 2.11.0~beta2,
// opendkim/opendkim.c); go-milter v0.4.1 returns quietly on io.EOF
// (session.go, HandleMilterCommands()). // (session.go, HandleMilterCommands()).
func CheckSocket(name, path string, required bool) Socket { func CheckSocket(name, path string, required bool) Socket {
s := Socket{Name: name, Path: path} s := Socket{Name: name, Path: path}
+2 -3
View File
@@ -17,8 +17,7 @@ import (
// The mail.log lines in this file are composed by hand, after the formats the // The mail.log lines in this file are composed by hand, after the formats the
// code cites (Postfix 3.10.13, src/global/log_adhoc.c and the others), unless a // code cites (Postfix 3.10.13, src/global/log_adhoc.c and the others), unless a
// comment says where one was seen; some carry no stamp, which the parsers // comment says where one was seen; some carry no stamp, which the parsers
// take as they come. One shape Postfix does not write here: opendkim lines in // take as they come.
// mail.log (OpenDKIM logs through syslog(3), to the container log - build/opendkim.conf).
// qmgr's expiry line, as Postfix 3.10.13 writes it (src/qmgr/qmgr_active.c, // qmgr's expiry line, as Postfix 3.10.13 writes it (src/qmgr/qmgr_active.c,
// qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to // qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to
@@ -823,7 +822,7 @@ func TestQueueLinesPicksOutOneMessage(t *testing.T) {
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>", "host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
// Same run of characters, longer id: not this message. // Same run of characters, longer id: not this message.
"host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)", "host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)",
"host opendkim[30]: 41E862C00D9E: DKIM-Signature field added (s=mail d=example.com)", "host postfix/qmgr[10]: 41E862C00D9E: from=<a@example.com>, size=500, nrcpt=1 (queue active)",
"host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)", "host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
) )
+2 -2
View File
@@ -26,8 +26,8 @@ import (
// it again. // it again.
// //
// It is counted at the warning, which is the one line that names the socket. // It is counted at the warning, which is the one line that names the socket.
// The reject lines do not: the same words are written when OpenDKIM does not // The reject lines do not: the same words are written when the DKIM signer
// answer and when the journal milter refuses under a level-2 limit. // does not answer and when the journal milter refuses under a level-2 limit.
// //
// Postfix 3.10.13, src/milter/milter8.c: milter8_connect() warns "connect to // Postfix 3.10.13, src/milter/milter8.c: milter8_connect() warns "connect to
// Milter service %s: %m" with the milter's configured name; then // Milter service %s: %m" with the milter's configured name; then
+7 -7
View File
@@ -36,9 +36,9 @@ func (d *deferredStore) AddSenderCheckDeferred(at time.Time, n int64, place stri
// with the panel stopped and for a refused From: (2026-10-12), and with the // with the panel stopped and for a refused From: (2026-10-12), and with the
// panel frozen in the middle of a message (cleanup 1339, 2026-10-14); the // panel frozen in the middle of a message (cleanup 1339, 2026-10-14); the
// stamps are put in ISO form. Around them are the lines of two other things // stamps are put in ISO form. Around them are the lines of two other things
// that use the same words - a level-2 refusal of the journal milter, OpenDKIM // that use the same words - a level-2 refusal of the journal milter, the DKIM
// not answering. Those - smtpd 1149, 1190 and 1201, the second client of // signer not answering. Those - smtpd 1149, 1190 and 1201, the second client
// 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed, // of 1183, smtpd 1300 and the signer's warning of cleanup 1339 - are composed,
// after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.10.13). // after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.10.13).
func TestDeferredCounter(t *testing.T) { func TestDeferredCounter(t *testing.T) {
st := &deferredStore{} st := &deferredStore{}
@@ -67,9 +67,9 @@ func TestDeferredCounter(t *testing.T) {
`Oct 12 16:23:40 mail postfix/smtpd[1190]: warning: Illegal address syntax from unknown[10.77.0.1] in MAIL command: <warning: connect to Milter service unix:/run/selfpost/sender.sock: milter>`, `Oct 12 16:23:40 mail postfix/smtpd[1190]: warning: Illegal address syntax from unknown[10.77.0.1] in MAIL command: <warning: connect to Milter service unix:/run/selfpost/sender.sock: milter>`,
`Oct 12 16:23:40 mail postfix/smtpd[1190]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<a@sender.e2e.test> proto=ESMTP helo=<x>`, `Oct 12 16:23:40 mail postfix/smtpd[1190]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<a@sender.e2e.test> proto=ESMTP helo=<x>`,
// OpenDKIM does not answer: another socket, not counted. // The signer does not answer: another socket, not counted.
`Oct 12 16:24:00 mail postfix/smtpd[1201]: connect from unknown[10.77.0.1]`, `Oct 12 16:24:00 mail postfix/smtpd[1201]: connect from unknown[10.77.0.1]`,
`Oct 12 16:24:00 mail postfix/smtpd[1201]: warning: connect to Milter service unix:/run/opendkim/opendkim.sock: Connection refused`, `Oct 12 16:24:00 mail postfix/smtpd[1201]: warning: connect to Milter service unix:/run/selfpost/sign.sock: Connection refused`,
`Oct 12 16:24:00 mail postfix/smtpd[1201]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`, `Oct 12 16:24:00 mail postfix/smtpd[1201]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`,
// The check stops answering in the middle of a message, the next day: // The check stops answering in the middle of a message, the next day:
@@ -81,8 +81,8 @@ func TestDeferredCounter(t *testing.T) {
`Oct 13 00:01:20 mail postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`, `Oct 13 00:01:20 mail postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`,
`Oct 13 00:01:20 mail postfix/cleanup[1339]: 8C1D43C75DA0: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 4.7.1 Service unavailable - try again later; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>`, `Oct 13 00:01:20 mail postfix/cleanup[1339]: 8C1D43C75DA0: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 4.7.1 Service unavailable - try again later; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>`,
// OpenDKIM hung in the middle of a message: another socket. // The signer hung in the middle of a message: another socket.
`Oct 13 00:01:30 mail postfix/cleanup[1339]: warning: milter unix:/run/opendkim/opendkim.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`, `Oct 13 00:01:30 mail postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sign.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`,
// The check's own verdict is a refusal, not a deferral, and it is // The check's own verdict is a refusal, not a deferral, and it is
// cleanup that writes it. // cleanup that writes it.
-327
View File
@@ -1,327 +0,0 @@
package milter
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
// opendkim 2.11.0~beta2-9.1+b1 (libopendkim/dkim-mailparse.c, identical
// there to upstream's develop branch), line for line. OpenDKIM calls it on the
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
// lower-cases the domain it returns, and signs with the key whose SigningTable
// pattern matches "user@domain" (dkimf_apply_signtable()). Whatever the port
// returns, OpenDKIM does: quoted strings and domain literals are kept with
// their delimiters, comments are dropped, a source route resets the address,
// an '@' before the first ':', ';' or ',' decides where the address starts,
// and encoded words are not decoded.
//
// The C works in place on a NUL-terminated buffer, writing the result over
// the input; here the input is read through at(), which gives 0 at its end as
// the C finds the terminator, and the result goes to a buffer of its own, in
// which 0 ends a string as '\0' does there.
// mailparse errors, DKIM_MAILPARSE_ERR_* (dkim-mailparse.c).
const (
mailparseOK = 0
mailparsePUnbalanced = 1 // unbalanced parentheses
mailparseQUnbalanced = 2 // unbalanced quotes
mailparseSUnbalanced = 3 // unbalanced square brackets
)
// mailparseSpecials is SPECIALS: "<>@,;:\\\"/[]?=".
var mailparseSpecials = func() (t [256]bool) {
for _, c := range []byte("<>@,;:\\\"/[]?=") {
t[c] = true
}
return t
}()
// mailparseSpace is isascii(c) && isspace(c) in the C locale.
func mailparseSpace(c byte) bool {
return c == ' ' || (c >= '\t' && c <= '\r')
}
type mailparser struct {
b []byte // the input up to its first NUL, as strlen() sees it
}
// at is *p: the byte, or the terminator at and beyond the end.
func (m *mailparser) at(i int) byte {
if i >= len(m.b) {
return 0
}
return m.b[i]
}
// matchingParen is dkim_mail_matching_paren(): the position of the close
// character that balances the open one before s, or e. An open character of
// 0 is never met inside the string, so for quotes and brackets it only skips
// to the close, past backslash escapes.
func (m *mailparser) matchingParen(s, e int, open, close byte) int {
paren := 1
for ; s < e; s++ {
c := m.b[s]
if c == close {
paren--
if paren == 0 {
break
}
} else if c == open {
paren++
} else if c == '\\' {
if m.at(s+1) != 0 {
s++
}
}
}
return s
}
// firstSpecial is dkim_mail_first_special(): the '<' that opens an address,
// or the ':', ';' or ',' that ends a part - or the '@' seen before it - or
// the end.
func (m *mailparser) firstSpecial(p, e int) (int, int) {
atPtr := -1
for ; p < e && m.at(p) != 0; p++ {
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return p, mailparsePUnbalanced
}
p++
}
}
if m.at(p) == 0 {
break
}
c := m.at(p)
switch {
case c == '"':
p = m.matchingParen(p+1, e, 0, '"')
if m.at(p) == 0 {
return p, mailparseQUnbalanced
}
case c == '[':
p = m.matchingParen(p+1, e, 0, ']')
if m.at(p) == 0 {
return p, mailparseSUnbalanced
}
case mailparseSpecials[c]:
if c == '<' {
return p, mailparseOK
} else if c == ':' || c == ';' || c == ',' {
if atPtr >= 0 {
return atPtr, mailparseOK
}
return p, mailparseOK
} else if c == '@' {
atPtr = p
}
default:
for m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
p--
}
}
return p, mailparseOK
}
// token is dkim_mail_token(): the next token from s - a quoted string or a
// domain literal with its delimiters, a special character alone, or an atom
// ('x') - its type (0 at the end), and whether whitespace outside a comment
// came before it (ws is set, never cleared, as in the C).
func (m *mailparser) token(s, e int, ws *bool) (typ int, start, end int, err int) {
p := s
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
*ws = true
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return 0, 0, 0, mailparsePUnbalanced
}
p++
}
}
if p >= e || m.at(p) == 0 {
return 0, 0, 0, mailparseOK
}
start = p
c := m.at(p)
switch {
case c == '"':
end = m.matchingParen(p+1, e, 0, '"')
typ = '"'
if m.at(end) != 0 {
end++
} else {
err = mailparseQUnbalanced
}
case c == '[':
end = m.matchingParen(p+1, e, 0, ']')
typ = '['
if m.at(end) != 0 {
end++
} else {
err = mailparseSUnbalanced
}
case mailparseSpecials[c]:
end = p + 1
typ = int(c)
default:
for p < e && m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
end = p
typ = 'x'
}
return typ, start, end, err
}
// mailParsed is what dkim_mail_parse() returns: the local part and the
// domain, each absent (NULL) or a string, and the error. rest is not the
// C's: where in the line it stopped reading, for the caller's question
// whether anything follows the address.
type mailParsed struct {
user, domain string
hasUser, hasDomain bool
err int
rest int
}
// dkimMailParse is dkim_mail_parse().
func dkimMailParse(line string) mailParsed {
if i := indexByte(line, 0); i >= 0 {
line = line[:i] // strlen()
}
m := &mailparser{b: []byte(line)}
e := len(m.b)
var out []byte
user, domain := -1, -1
result := func(err, rest int) mailParsed {
r := mailParsed{err: err, rest: rest}
if user >= 0 {
r.user, r.hasUser = cstring(out[user:]), true
}
if domain >= 0 {
r.domain, r.hasDomain = cstring(out[domain:]), true
}
return r
}
ws := false
pos := 0
for {
special, err := m.firstSpecial(pos, e)
if err != 0 {
return result(err, special)
}
switch m.at(special) {
case '<':
// display name <address>
pos = special + 1
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == '>' || typ == 0 {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
} else if typ == ',' || typ == ':' {
// source route punctuation
user, domain = -1, -1
} else {
if user < 0 {
user = len(out)
}
out = append(out, m.b[ts:te]...)
}
pos = te
}
case ';', ':', ',':
// skip a group name or result
pos = special + 1
default:
// (display name) addr(display name)ess
ws = false
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == 0 || typ == ',' || typ == ';' {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
ws = false
} else {
if user < 0 {
user = len(out)
} else if typ == 'x' && ws {
out = append(out, ' ')
}
out = append(out, m.b[ts:te]...)
ws = false
}
pos = te
}
}
}
}
// moreAfter says whether anything but list and group punctuation follows
// position rest of line: another address, or text after the one found.
func moreAfter(line string, rest int) bool {
if i := indexByte(line, 0); i >= 0 {
line = line[:i]
}
m := &mailparser{b: []byte(line)}
ws := false
for pos := rest; ; {
typ, _, te, err := m.token(pos, len(m.b), &ws)
if err != 0 {
return true
}
switch typ {
case 0:
return false
case ',', ';':
pos = te
default:
return true
}
}
}
func indexByte(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
// cstring reads a NUL-terminated string from b.
func cstring(b []byte) string {
if i := indexByte(string(b), 0); i >= 0 {
return string(b[:i])
}
return string(b)
}
-163
View File
@@ -1,163 +0,0 @@
package milter
import (
"bufio"
"encoding/hex"
"fmt"
"math/rand"
"os"
"os/exec"
"strings"
"testing"
)
// mailparseCases are lines whose result is written down here, read off
// dkim-mailparse.c by hand and confirmed against it by
// TestMailParseMatchesOpenDKIM.
var mailparseCases = []struct {
line string
user, domain string // "-" for absent
err int
}{
{"user@example.com", "user", "example.com", 0},
{" Name <user@example.com>", "user", "example.com", 0},
{`"Some One" <user@example.com>`, "user", "example.com", 0},
{"user@example.com (Some One)", "user", "example.com", 0},
{"Some One <user@ex(comment)ample.com>", "user", "example.com", 0},
// An '@' before the first ',' decides: the address is read from the start.
{"ceo@other.com <me@mine.com>", "me", "mine.com", 0},
{"me@mine.com, you@yours.com", "me", "mine.com", 0},
// Quoted local parts keep their quotes; an '@' inside them is no '@'.
{`"x@other.com"@mine.com`, `"x@other.com"`, "mine.com", 0},
{`<"a b"@mine.com>`, `"a b"`, "mine.com", 0},
// A source route is dropped.
{"<@relay.example,@r2.example:u@mine.com>", "u", "mine.com", 0},
// Group syntax: the name is skipped.
{"group: u@mine.com;", "u", "mine.com", 0},
// No local part: the domain is taken for it, so OpenDKIM has both.
{"@mine.com", "mine.com", "mine.com", 0},
// Whitespace between atoms of the default form becomes one space.
{"a b@mine.com", "a b", "mine.com", 0},
// Unbalanced.
{"Name (comment <u@mine.com>", "-", "-", mailparsePUnbalanced},
{`"open <u@mine.com>`, "-", "-", mailparseQUnbalanced},
{"u@[1.2.3.4", "-", "-", mailparseSUnbalanced},
// Encoded words are not decoded.
{"=?utf-8?q?boss=40other.com?= <u@mine.com>", "u", "mine.com", 0},
// No address at all.
{"Undisclosed recipients:;", "-", "-", 0},
{"", "-", "-", 0},
}
func parsedParts(r mailParsed) (string, string) {
u, d := "-", "-"
if r.hasUser {
u = r.user
}
if r.hasDomain {
d = r.domain
}
return u, d
}
func TestMailParseCases(t *testing.T) {
for _, c := range mailparseCases {
r := dkimMailParse(c.line)
u, d := parsedParts(r)
if r.err != c.err || (c.err == 0 && (u != c.user || d != c.domain)) {
t.Errorf("%q: user %q domain %q err %d; want %q %q %d", c.line, u, d, r.err, c.user, c.domain, c.err)
}
}
}
func TestMoreAfter(t *testing.T) {
for line, want := range map[string]bool{
"user@example.com": false,
"Name <user@example.com>": false,
"Name <user@example.com> (note)": false,
"group: u@mine.com;": false,
"me@mine.com, you@yours.com": true,
"Name <a@x>, b@y": true,
"Name <a@x> trailing": true,
} {
r := dkimMailParse(line)
if got := moreAfter(line, r.rest); got != want {
t.Errorf("%q: moreAfter %v, want %v", line, got, want)
}
}
}
// mailparseCorpus is the cases above and n random lines over an alphabet
// weighted toward what the parser treats specially.
func mailparseCorpus(n int) []string {
lines := []string{}
for _, c := range mailparseCases {
lines = append(lines, c.line)
}
alphabet := []string{"<", ">", "@", ",", ";", ":", "\\", `"`, "/", "[", "]", "?", "=", "(", ")",
" ", "\t", "\n", "\r", ".", "a", "b", "x", "mine.com", "other.com", "\xc3\xa9", "\xff", "=?utf-8?q?", "?="}
rng := rand.New(rand.NewSource(20261015))
for i := 0; i < n; i++ {
var b strings.Builder
for j := rng.Intn(24); j >= 0; j-- {
b.WriteString(alphabet[rng.Intn(len(alphabet))])
}
lines = append(lines, b.String())
}
return lines
}
// TestMailParseMatchesOpenDKIM feeds the corpus to OpenDKIM's own C
// dkim_mail_parse() through testdata/mailparse_harness.c, built against the
// opendkim source the image runs, and wants the port's answer for every line.
// It runs where MAILPARSE_C names the built harness.
func TestMailParseMatchesOpenDKIM(t *testing.T) {
bin := os.Getenv("MAILPARSE_C")
if bin == "" {
t.Skip("MAILPARSE_C not set (see testdata/mailparse_harness.c)")
}
lines := mailparseCorpus(200000)
var in strings.Builder
for _, l := range lines {
in.WriteString(hex.EncodeToString([]byte(l)))
in.WriteByte('\n')
}
cmd := exec.Command(bin)
cmd.Stdin = strings.NewReader(in.String())
out, err := cmd.Output()
if err != nil {
t.Fatalf("harness: %v", err)
}
sc := bufio.NewScanner(strings.NewReader(string(out)))
sc.Buffer(make([]byte, 1<<20), 1<<20)
enc := func(present bool, s string) string {
if !present {
return "-"
}
return "=" + hex.EncodeToString([]byte(s))
}
diffs, failed := 0, 0
for i := 0; sc.Scan(); i++ {
r := dkimMailParse(lines[i])
got := fmt.Sprintf("%d %s %s", r.err, enc(r.hasUser, r.user), enc(r.hasDomain, r.domain))
want := sc.Text()
if r.err != 0 {
// On an error the C returns before it writes the result's end, so
// what it prints of user and domain runs on into the unread
// input; OpenDKIM uses neither then (mlfi_eoh(): status != 0 is
// BADFORMAT, no signature). The error itself must agree.
failed++
got, want = fmt.Sprint(r.err), strings.Fields(want)[0]
}
if got != want {
diffs++
if diffs <= 20 {
t.Errorf("line %q: port %s, OpenDKIM %s", lines[i], got, want)
}
}
}
if diffs > 0 {
t.Fatalf("%d of %d lines differ", diffs, len(lines))
}
t.Logf("%d lines, the port and OpenDKIM agree on every one (%d of them errors, compared by code)", len(lines), failed)
}
+1 -1
View File
@@ -2,7 +2,7 @@
// sender check (sender.go) and the inbound journal (inbound.go). // sender check (sender.go) and the inbound journal (inbound.go).
// //
// The journal-milter is a lightweight milter (architecture.md § Mail path) // The journal-milter is a lightweight milter (architecture.md § Mail path)
// attached to Postfix's smtpd_milters alongside OpenDKIM. On the receive path it reads the SASL login, From, recipients and // attached to Postfix's smtpd_milters after the sender check and the signer. On the receive path it reads the SASL login, From, recipients and
// Subject of each accepted message and records one send-log row per (queue-id, // Subject of each accepted message and records one send-log row per (queue-id,
// recipient), giving the panel a structured, filterable history that raw // recipient), giving the panel a structured, filterable history that raw
// mail.log cannot. // mail.log cannot.
-183
View File
@@ -1,183 +0,0 @@
package milter
import (
"bufio"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/emersion/go-milter"
)
// TestPortAgainstOpenDKIM checks the port where it matters, in OpenDKIM
// itself: for every From: line, the domain OpenDKIM signs the message for
// must be the one the port predicts - the port's address, matched against
// the SigningTable as dkimf_apply_signtable() matches it, cut to 256 bytes
// (MAXADDRESS) - or both must say no signature. It runs where
// OPENDKIM_PROBE_SOCKET names a running OpenDKIM's socket and
// OPENDKIM_PROBE_TABLE its refile SigningTable of "*@<domain>" lines:
// test/upstream/opendkim-probe.sh starts one from the image and runs this
// (audit 2026-10-12, stage 2c).
func TestPortAgainstOpenDKIM(t *testing.T) {
sock, table := os.Getenv("OPENDKIM_PROBE_SOCKET"), os.Getenv("OPENDKIM_PROBE_TABLE")
if sock == "" || table == "" {
t.Skip("OPENDKIM_PROBE_SOCKET and OPENDKIM_PROBE_TABLE not set")
}
signed := map[string]bool{}
f, err := os.Open(table)
if err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(f)
for sc.Scan() {
if fields := strings.Fields(sc.Text()); len(fields) > 0 && strings.HasPrefix(fields[0], "*@") {
signed[strings.ToLower(fields[0][2:])] = true
}
}
f.Close()
const own, other, server = "sender.e2e.test", "other.e2e.test", "mail.e2e.test"
lines := probeLines(t, own, other, server)
agree, signedCount := 0, 0
for i, line := range lines {
want := predictSigner(line, signed)
got, err := opendkimSigns(sock, line)
if err != nil {
t.Fatalf("From: %q: %v", line, err)
}
if i >= len(lines)-2 && got != other {
// The padded lines: cut at 256 bytes they end in "@<other>",
// and OpenDKIM signs them with that domain's key - the reason
// the sender check refuses an address over 256 bytes.
t.Errorf("a padded line of %d bytes: OpenDKIM signs for %q, not %q", len(line), got, other)
}
if got != "" {
signedCount++
}
if got != want {
t.Errorf("From: %q: OpenDKIM signs for %q, the port predicts %q", line, got, want)
continue
}
agree++
}
t.Logf("%d From: lines, OpenDKIM and the port agree on %d; %d signed", len(lines), agree, signedCount)
}
// predictSigner is the domain OpenDKIM signs a message with this From: line
// for, by the port: none when it reads no address; otherwise the domain of
// the SigningTable whose "^.*@<domain>$" (case-insensitive) matches
// "user@domain" as snprintf() leaves it in MAXADDRESS + 1 bytes, the domain
// first cut to DKIM_MAXHOSTNAMELEN by strlcpy() and lower-cased.
func predictSigner(line string, signed map[string]bool) string {
r := dkimMailParse(line)
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
return ""
}
domain := r.domain
if len(domain) > 256 {
domain = domain[:256]
}
addr := r.user + "@" + asciiLower(domain)
if len(addr) > 256 {
addr = addr[:256]
}
d := asciiLower(addr[strings.LastIndexByte(addr, '@')+1:])
if signed[d] {
return d
}
return ""
}
// opendkimSigns plays one message to OpenDKIM as Postfix would for a client
// it trusts (127.0.0.1, its InternalHosts by default) and returns the d= of
// the signature it adds, lower-cased, or "".
func opendkimSigns(sock, from string) (string, error) {
cl := milter.NewClientWithOptions("unix", sock, milter.ClientOptions{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ActionMask: milter.OptAddHeader | milter.OptChangeHeader,
})
defer cl.Close()
s, err := cl.Session()
if err != nil {
return "", err
}
defer s.Close()
// A step OpenDKIM answers with anything but "continue" ends the message
// for it - an accept where it has nothing to do (libmilter, engine.c) -
// and it answers nothing after; no signature then.
step := func(act *milter.Action, err error) (bool, error) {
if err != nil {
return false, err
}
return act.Code == milter.ActContinue, nil
}
if ok, err := step(s.Conn("localhost", milter.FamilyInet, 40000, "127.0.0.1")); !ok {
return "", err
}
if ok, err := step(s.Mail("<probe@sender.e2e.test>", nil)); !ok {
return "", err
}
if ok, err := step(s.Rcpt("<user@elsewhere.example>", nil)); !ok {
return "", err
}
for _, h := range [][2]string{{"From", from}, {"To", "user@elsewhere.example"}, {"Subject", "probe"}, {"Date", "Thu, 15 Oct 2026 03:00:00 +0000"}} {
if ok, err := step(s.HeaderField(h[0], h[1])); !ok {
return "", err
}
}
if ok, err := step(s.HeaderEnd()); !ok {
return "", err
}
// No body chunk: OpenDKIM signs an empty body as well.
mods, _, err := s.End()
if err != nil {
return "", fmt.Errorf("end: %w", err)
}
for _, m := range mods {
if !strings.EqualFold(m.HeaderName, "DKIM-Signature") {
continue
}
for _, tag := range strings.Split(m.HeaderValue, ";") {
k, v, ok := strings.Cut(strings.TrimSpace(tag), "=")
if ok && strings.TrimSpace(k) == "d" {
return strings.ToLower(strings.TrimSpace(v)), nil
}
}
}
return "", nil
}
// probeLines are the shared files' lines and several hundred composed from
// pieces: display names plain, quoted, escaped and encoded, comments nested
// and left open, groups, lists, brackets, source routes, domain literals and
// quoted local parts - with the other sender's name, and the server's, in
// each place - and the line padded to be cut at 256 bytes.
func probeLines(t *testing.T, own, other, server string) []string {
var lines []string
for _, file := range []string{"from-lines-passed.txt", "from-lines-other.txt"} {
lines = append(lines, fromLines(t, file, own, other)...)
}
names := []string{"", "Shop ", `"Shop, Inc." `, `"x@` + other + `" `, "x@" + other + " ", `"a\"b <x@` + other + `>" `,
"=?utf-8?q?x=40" + other + "?= ", "(x@" + other + ") ", "((x@" + other + ")) ", "(open ", `"open `, "[x@" + other + "] ",
"group: ", other + ": "}
addrs := []string{
"a@" + own, "<a@" + own + ">", "<a@" + other + ">", "a@" + other, "<a@" + server + ">", "a@" + strings.ToUpper(own),
`<"x@` + other + `"@` + own + `>`, `"x@` + own + `"@` + other, "<@" + other + ":a@" + own + ">", "<@" + own + ":a@" + other + ">",
"a@[192.0.2.1]", "<a@" + own + ">, b@" + other, "a@" + own + ", <b@" + other + ">", "a(c)@" + own, "a@" + own + "(x@" + other + ")",
"a@ " + own, "a @" + own, "<a@" + own, "a@" + own + ";", "@" + own, "<>", "a", `"a b"@` + own, `a\@` + other + "@" + own,
"a@" + own + " x@" + other,
}
for _, n := range names {
for _, a := range addrs {
lines = append(lines, n+a)
}
}
// Cut at 256 bytes, the quoted local part ends in "@<other>".
pad := strings.Repeat("a", 256-len("@"+other)-1)
lines = append(lines, `"`+pad+`@`+other+`x"@`+own, `<"`+pad+`@`+other+`x"@`+own+`>`)
return lines
}
+29 -61
View File
@@ -22,12 +22,11 @@ import (
// The sender check: a message's From: line is held against the application // The sender check: a message's From: line is held against the application
// that sends it, under the rule Postfix holds the envelope sender to // that sends it, under the rule Postfix holds the envelope sender to
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by // (docs/plans/from-header-binding.md). The signer signs with the key of the
// that line, so without the check an application could have mail signed as // domain the login belongs to (sign.go), and a receiver holds that signature
// another domain of the server, or as the server itself. // to the domain of this line (DMARC, RFC 7489 § 3.1.1), so without the check
// // an application could send under the server's signature as another domain
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the // of the server, or as the server itself.
// address judged is the one OpenDKIM signs for.
// //
// Unlike the journal milter this one is attached strictly // Unlike the journal milter this one is attached strictly
// (default_action=tempfail in build/postfix-config.sh): while it does not // (default_action=tempfail in build/postfix-config.sh): while it does not
@@ -42,9 +41,7 @@ import (
// Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): // Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with an owner by strcasecmp_utf8(); // the SASL login is compared with an owner by strcasecmp_utf8();
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers // src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2, // "451 4.7.1" for a milter that does not answer.
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
// domain is that of the first From: field, as dkim_mail_parse() reads it.
// tableFile is a table of the mail path that the panel rewrites and the check // tableFile is a table of the mail path that the panel rewrites and the check
// reads: it is read again when it has changed. An unreadable file is an error // reads: it is read again when it has changed. An unreadable file is an error
@@ -208,8 +205,8 @@ func splitTableLine(line string) (key, value string, ok bool) {
// Case is folded in ASCII only. Postfix folds as Unicode (src/util/casefold.c // Case is folded in ASCII only. Postfix folds as Unicode (src/util/casefold.c
// with ICU, which Debian builds with), but every key and login the panel // with ICU, which Debian builds with), but every key and login the panel
// writes is ASCII; a Unicode fold would only let a look-alike pass - the // writes is ASCII; a Unicode fold would only let a look-alike pass - the
// Kelvin sign for "k" - for an address OpenDKIM, folding ASCII, does not // Kelvin sign for "k" - for an address that is not the domain's (review of
// sign (review of stage 2c). // stage 2c).
func (b *Bindings) Allowed(login, addr string) (bool, error) { func (b *Bindings) Allowed(login, addr string) (bool, error) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
@@ -223,8 +220,10 @@ func (b *Bindings) Allowed(login, addr string) (bool, error) {
if bare := withoutExtension(addr); bare != addr { if bare := withoutExtension(addr); bare != addr {
keys = append(keys, bare) keys = append(keys, bare)
} }
if d := domainOf(addr); d != "" { // The domain as written, folded in ASCII alone: domainOf folds as
keys = append(keys, "@"+d) // Unicode, which would make the Kelvin sign a "k".
if at := strings.LastIndexByte(addr, '@'); at >= 0 && at < len(addr)-1 {
keys = append(keys, addr[at:])
} }
for _, k := range keys { for _, k := range keys {
if owners, found := b.logins[k]; found { if owners, found := b.logins[k]; found {
@@ -380,53 +379,23 @@ func (s *senderSession) judge() (shown, why string, err error) {
default: default:
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
} }
// The value is the one OpenDKIM gets - Postfix hands both milters the // The line must be one address in RFC 5322's grammar (net/mail), the
// field as it came, folded lines and all, and with the leading space each // address a mail program shows; a display name may say anything. A line
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with // a lenient program could show as another address - "ceo@bank.com
// OpenDKIM's own parser (mailparse.go). So the address judged here is the // <me@mine.com>", "ceo@bank.com:me@mine.com", "<b@evil.com, a@mine.com>"
// address OpenDKIM signs for, whatever the display name, a comment or a // - is refused (review of stage 2c, F1). The value comes as the client
// quoted string around it holds; the domain of the key it signs with is // wrote it, folded lines and all (assumptions B-PM12). And that address,
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c). // its local part unquoted as Postfix holds the envelope's (Postfix
// 3.10.13, src/global/tok822_parse.c), is the one held against the
// bindings: "x"@mine.com is x@mine.com, which may be another
// application's (F2).
raw := s.froms[0] raw := s.froms[0]
shown = printable(raw) shown = printable(raw)
r := dkimMailParse(raw)
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
// OpenDKIM does not sign then either (mlfi_eoh(): BADFORMAT).
return shown, "it holds no address OpenDKIM can read", nil
}
if moreAfter(raw, r.rest) {
// OpenDKIM takes the first; a reader may show the others.
return shown, "it must be one address", nil
}
signedDomain := asciiLower(r.domain)
addr := r.user + "@" + signedDomain
// OpenDKIM matches its SigningTable against "user@domain" as
// snprintf() leaves it in a buffer of MAXADDRESS + 1 = 257 bytes
// (opendkim.c, dkimf_apply_signtable(); libopendkim/dkim-internal.h).
// Cut there, a quoted local part could end in another tenant's
// "@domain" and pick that tenant's key; an address that fits is read
// whole, and its domain (DKIM_MAXHOSTNAMELEN, 256) with it.
if len(addr) > 256 {
return printable(addr), "the address is longer than OpenDKIM reads whole", nil
}
// The address a mail program shows. OpenDKIM's parser reads lines no
// standard reader takes for one address - "ceo@bank.com:me@mine.com",
// "ceo@bank.com <me@mine.com>", "<b@evil.com, a@mine.com>" - and signs
// for the last of them while a lenient program shows the first. So the
// line must also be one address in RFC 5322's grammar (net/mail), at the
// domain OpenDKIM signs for; a quoted display name may say anything.
// And that address, its local part unquoted as Postfix holds the
// envelope's (Postfix 3.10.13, src/global/tok822_parse.c), is the one
// held against the bindings: "x"@mine.com is x@mine.com, which may be
// another application's (review of stage 2c, F1, F2).
list, perr := fromParser.ParseList(unfold(raw)) list, perr := fromParser.ParseList(unfold(raw))
if perr != nil || len(list) != 1 { if perr != nil || len(list) != 1 {
return shown, "it must be one address, as a mail program reads it", nil return shown, "it must be one address, as a mail program reads it", nil
} }
seen := asciiLower(list[0].Address) seen := asciiLower(list[0].Address)
if domainOf(seen) != signedDomain {
return shown, "a mail program would show another address than the one signed", nil
}
shown = printable(seen) shown = printable(seen)
ok, err := s.bind.Allowed(s.login, seen) ok, err := s.bind.Allowed(s.login, seen)
if err != nil { if err != nil {
@@ -452,8 +421,8 @@ func unfold(v string) string {
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v) return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
} }
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and // asciiLower lower-cases A-Z only, as Postfix's table lookups do for the
// Postfix's table lookups do; strings.ToLower would also fold characters // ASCII keys the panel writes; strings.ToLower would also fold characters
// outside ASCII - the Kelvin sign to "k" - into names they are not. // outside ASCII - the Kelvin sign to "k" - into names they are not.
func asciiLower(v string) string { func asciiLower(v string) string {
b := []byte(v) b := []byte(v)
@@ -571,12 +540,11 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refus
}, },
Actions: 0, // read-only Actions: 0, // read-only
// The headers decide; and each header value with its leading space, // The headers decide; and each header value with its leading space,
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(), // as the client wrote it, the bytes the signer reads too (sign.go).
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix // Postfix 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps
// 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps the event // the event mask a milter of any version asks for (go-milter v0.4.1
// mask a milter of any version asks for (go-milter v0.4.1 says // says version 2), and milter8_header() drops the space only for a
// version 2), and milter8_header() drops the space only for a milter // milter without SMFIP_HDR_LEADSPC.
// without SMFIP_HDR_LEADSPC.
Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace, Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace,
} }
+19 -19
View File
@@ -96,9 +96,9 @@ func TestSenderCheck(t *testing.T) {
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""}, {"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""}, {"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""}, {"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""}, {"the leading space the check asks Postfix to keep", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
// Whatever the name, a comment or a quoted local part holds: OpenDKIM // Whatever the name, a comment or a quoted local part holds: the
// signs for the address it reads, and that is the one judged. // address a mail program reads is the one judged.
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""}, {"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""},
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""}, {"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""},
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""}, {"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""},
@@ -107,9 +107,9 @@ func TestSenderCheck(t *testing.T) {
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""}, {"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""},
{"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, false, "as a mail program reads it"}, {"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, false, "as a mail program reads it"},
{"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""}, {"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""},
{"no local part: OpenDKIM takes the domain for it", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"}, {"no local part", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
// Lines OpenDKIM signs for the application's domain while a lenient // Lines a lenient mail program could show as another address
// mail program shows another address first (review of stage 2c, F1). // (review of stage 2c, F1).
{"another address before a colon", "shop", [][2]string{from("ceo@bank.example:me@shop.example.org")}, false, "as a mail program reads it"}, {"another address before a colon", "shop", [][2]string{from("ceo@bank.example:me@shop.example.org")}, false, "as a mail program reads it"},
{"a route-like colon inside the brackets", "shop", [][2]string{from("Name <ceo@bank.example:me@shop.example.org>")}, false, "as a mail program reads it"}, {"a route-like colon inside the brackets", "shop", [][2]string{from("Name <ceo@bank.example:me@shop.example.org>")}, false, "as a mail program reads it"},
{"a list inside the brackets", "shop", [][2]string{from("<b@evil.example, a@shop.example.org>")}, false, "as a mail program reads it"}, {"a list inside the brackets", "shop", [][2]string{from("<b@evil.example, a@shop.example.org>")}, false, "as a mail program reads it"},
@@ -119,9 +119,8 @@ func TestSenderCheck(t *testing.T) {
{"another application's address, quoted", "shop", [][2]string{from(`"desk"@shop.example.org`)}, false, "not an address this application sends as"}, {"another application's address, quoted", "shop", [][2]string{from(`"desk"@shop.example.org`)}, false, "not an address this application sends as"},
{"its own bound address, quoted", "shop2", [][2]string{from(`"desk"@shop.example.org`)}, true, ""}, {"its own bound address, quoted", "shop2", [][2]string{from(`"desk"@shop.example.org`)}, true, ""},
// A look-alike of the domain in a character a Unicode fold maps // A look-alike of the domain in a character a Unicode fold maps
// to ASCII (F3): OpenDKIM would not sign it, and it is not the // to ASCII (F3): it is not the application's.
// application's. {"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "not an address this application sends as"},
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "a mail program would show another address"},
{"the same domain in ASCII", "kiosk", [][2]string{from("a@kiosk.example.net")}, true, ""}, {"the same domain in ASCII", "kiosk", [][2]string{from("a@kiosk.example.net")}, true, ""},
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"}, {"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
@@ -134,14 +133,14 @@ func TestSenderCheck(t *testing.T) {
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"}, {"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"}, {"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"}, {"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
{"not an address", "shop", [][2]string{from("the shop")}, false, "no address OpenDKIM can read"}, {"not an address", "shop", [][2]string{from("the shop")}, false, "as a mail program reads it"},
{"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"}, {"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"},
// Past 256 bytes OpenDKIM matches its SigningTable against a cut // The key is the login's, so a long quoted local part naming another
// "user@domain" - which could end in another tenant's domain. // domain picks nothing: OpenDKIM's 256-byte cut went with it.
{"an address longer than OpenDKIM reads", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, false, "longer than OpenDKIM reads whole"}, {"a long quoted local part naming another domain", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, true, ""},
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"}, {"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"}, {"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "no address OpenDKIM can read"}, {"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "as a mail program reads it"},
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"}, {"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"}, {"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
} }
@@ -389,8 +388,8 @@ func fromLines(t *testing.T, file, domain, other string) []string {
} }
// No line of the shared file of lines naming another sender is passed to be // No line of the shared file of lines naming another sender is passed to be
// signed with that sender's key: a line the check passes is one OpenDKIM // passed as that sender: a line the check passes is one a mail program reads
// reads the application's own address in (the e2e looks at the signature). // as an address of the application's own domain.
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) { func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps)) bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") { for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
@@ -398,14 +397,15 @@ func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
if resp != milter.RespContinue { if resp != milter.RespContinue {
continue continue
} }
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" { list, err := fromParser.ParseList(unfold(line))
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain) if err != nil || len(list) != 1 || domainOf(asciiLower(list[0].Address)) != "shop.example.org" {
t.Errorf("From: %q passed, and a mail program reads it as %v (%v)", line, list, err)
} }
} }
} }
// Every line of the shared file is passed for the domain's application; the // Every line of the shared file is passed for the domain's application; the
// e2e then checks OpenDKIM signs each for that domain alone. // e2e then checks each arrives signed for that domain alone.
func TestSenderCheckPassesTheSharedLines(t *testing.T) { func TestSenderCheckPassesTheSharedLines(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps)) bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range passedFromLines(t, "shop.example.org") { for _, line := range passedFromLines(t, "shop.example.org") {
+33 -31
View File
@@ -27,10 +27,11 @@ import (
"github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-msgauth/dkim"
) )
// signHeaders is the set of header names signed when present: OpenDKIM's // signHeaders is the set of header names signed when present: RFC 6376
// dkim_should_signhdrs[], RFC 6376 § 5.4.1's list (OpenDKIM 2.11.0~beta2, // § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM
// libopendkim/dkim.c:223-246), which OpenDKIM signed with until now. Every // 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until
// occurrence is signed, in message order, and From once more (signLines). // 2.8.0. Every occurrence is signed, in message order, and From once more
// (signLines).
var signHeaders = map[string]bool{ var signHeaders = map[string]bool{
"from": true, "reply-to": true, "subject": true, "date": true, "to": true, "cc": true, "from": true, "reply-to": true, "subject": true, "date": true, "to": true, "cc": true,
"resent-date": true, "resent-from": true, "resent-sender": true, "resent-to": true, "resent-cc": true, "resent-date": true, "resent-from": true, "resent-sender": true, "resent-to": true, "resent-cc": true,
@@ -49,11 +50,11 @@ var signHeaders = map[string]bool{
// far above Postfix's own bounds on a transaction. // far above Postfix's own bounds on a transaction.
const signLimit = time.Hour const signLimit = time.Hour
// SignKeys reads the domains the server signs for, their selectors and key // SignKeys reads the names the server signs for and their selectors from the
// files from the KeyTable the panel writes (internal/domain, renderTables(): // selector table the panel writes (internal/domain, renderSelectors(): a line
// "<domain> <domain>:<selector>:<keysDir>/<domain>/<selector>.private"), the // "<name> <selector>" each); the key of a name is
// table OpenDKIM reads too until it leaves the image. A key file is read // keysDir/<name>/<selector>.private (domain.KeyPath). The table is read again
// when it changes, and only from under keysDir. // when it changes, a key file when it does.
type SignKeys struct { type SignKeys struct {
table, keysDir string table, keysDir string
@@ -81,13 +82,13 @@ type fileStamp struct {
func stampOf(fi os.FileInfo) fileStamp { return fileStamp{fi.ModTime(), fi.Size()} } func stampOf(fi os.FileInfo) fileStamp { return fileStamp{fi.ModTime(), fi.Size()} }
// errNoKey is a domain the KeyTable has no line for. // errNoKey is a domain the selector table has no line for.
var errNoKey = errors.New("no signing key") var errNoKey = errors.New("no signing key")
// NewSignKeys reads keyTable; keysDir is the directory every key in it must // NewSignKeys reads the selector table at selectors and the keys under
// be under. // keysDir.
func NewSignKeys(keyTable, keysDir string) *SignKeys { func NewSignKeys(selectors, keysDir string) *SignKeys {
return &SignKeys{table: keyTable, keysDir: keysDir, keys: map[string]loadedKey{}} return &SignKeys{table: selectors, keysDir: keysDir, keys: map[string]loadedKey{}}
} }
// Lookup gives the selector and key of domain. // Lookup gives the selector and key of domain.
@@ -120,39 +121,39 @@ func (k *SignKeys) Lookup(domain string) (string, crypto.Signer, error) {
func (k *SignKeys) readTable() error { func (k *SignKeys) readTable() error {
fi, err := os.Stat(k.table) fi, err := os.Stat(k.table)
if err != nil { if err != nil {
return fmt.Errorf("key table: %w", err) return fmt.Errorf("selector table: %w", err)
} }
if k.rows != nil && k.stamp == stampOf(fi) { if k.rows != nil && k.stamp == stampOf(fi) {
return nil return nil
} }
data, err := os.ReadFile(k.table) data, err := os.ReadFile(k.table)
if err != nil { if err != nil {
return fmt.Errorf("key table: %w", err) return fmt.Errorf("selector table: %w", err)
} }
rows := map[string]keyRow{} rows := map[string]keyRow{}
for n, line := range strings.Split(string(data), "\n") { for n, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
} }
name, value, ok := strings.Cut(line, " ") name, selector, ok := strings.Cut(line, " ")
// Domain and selector never hold a colon (internal/domain, // The two are names of a directory and a file under keysDir: nothing
// opendkimForbidden); the path takes the rest. // that would lead out of it (internal/domain, assertConfigSafe()).
parts := strings.SplitN(value, ":", 3) if !ok || !pathToken(name) || !pathToken(selector) {
if !ok || len(parts) != 3 || !strings.EqualFold(parts[0], name) { return fmt.Errorf("selector table: line %d is not \"<name> <selector>\"", n+1)
return fmt.Errorf("key table: line %d is not \"<domain> <domain>:<selector>:<path>\"", n+1)
} }
domain, selector, path := strings.ToLower(name), parts[1], parts[2] domain := strings.ToLower(name)
// The panel writes exactly this path (renderTables); anything else rows[domain] = keyRow{selector, filepath.Join(k.keysDir, domain, selector+".private")}
// is not read.
if want := filepath.Join(k.keysDir, domain, selector+".private"); filepath.Clean(path) != want {
return fmt.Errorf("key table: line %d names %s, not %s", n+1, path, want)
}
rows[domain] = keyRow{selector, path}
} }
k.rows, k.stamp = rows, stampOf(fi) k.rows, k.stamp = rows, stampOf(fi)
return nil return nil
} }
// pathToken reports whether v can name a directory or a file of the key
// tree: not empty, no separator or white space, not dots alone.
func pathToken(v string) bool {
return v != "" && !strings.ContainsAny(v, " \t\r\n:/\\") && strings.Trim(v, ".") != ""
}
// readKey reads a PEM private key: PKCS#1 as internal/domain writes it // readKey reads a PEM private key: PKCS#1 as internal/domain writes it
// (dkim.go, writePrivateKeyPEM()), PKCS#8 as well. // (dkim.go, writePrivateKeyPEM()), PKCS#8 as well.
func readKey(path string) (crypto.Signer, error) { func readKey(path string) (crypto.Signer, error) {
@@ -281,7 +282,7 @@ func watch(signer *dkim.Signer, expired, done chan struct{}) {
// with CRLF; and the empty line that ends the block. The names: every header // with CRLF; and the empty line that ends the block. The names: every header
// in signHeaders, in message order, and From once more, so that a From: // in signHeaders, in message order, and From once more, so that a From:
// added later breaks the signature (RFC 6376 § 5.4.2; OversignHeaders From in // added later breaks the signature (RFC 6376 § 5.4.2; OversignHeaders From in
// OpenDKIM's configuration until now; go-msgauth v0.7.0, dkim/sign.go, // OpenDKIM's configuration until 2.8.0; go-msgauth v0.7.0, dkim/sign.go,
// NewSigner(): a name with no instance left is kept in h= and hashes // NewSigner(): a name with no instance left is kept in h= and hashes
// nothing). // nothing).
func signLines(headers []field) ([]string, []byte) { func signLines(headers []field) ([]string, []byte) {
@@ -315,7 +316,8 @@ func (s *signSession) BodyChunk(chunk []byte, m *milter.Modifier) (milter.Respon
} }
// Body is the end of the message: the signature goes in at the top, where // Body is the end of the message: the signature goes in at the top, where
// OpenDKIM put it (opendkim/opendkim.c:3673, dkimf_insheader(ctx, 0, ...)). // OpenDKIM put it until 2.8.0 (OpenDKIM 2.11.0~beta2, opendkim/opendkim.c:3673,
// dkimf_insheader(ctx, 0, ...)).
// Postfix 3.10.13, src/milter/milter8.c:1445-1465, SMFIR_INSHEADER: the // Postfix 3.10.13, src/milter/milter8.c:1445-1465, SMFIR_INSHEADER: the
// index is passed on plus one; src/cleanup/cleanup_milter.c:1193, // index is passed on plus one; src/cleanup/cleanup_milter.c:1193,
// cleanup_ins_header(): "Index 1 is the top-most header" - above Postfix's // cleanup_ins_header(): "Index 1 is the top-most header" - above Postfix's
+21 -10
View File
@@ -18,8 +18,8 @@ import (
"github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-msgauth/dkim"
) )
// signFixture is a key directory and KeyTable laid out as internal/domain // signFixture is a key directory and selector table laid out as
// writes them (renderTables(), writePrivateKeyPEM(): PKCS#1 PEM) for // internal/domain writes them (renderSelectors(), writePrivateKeyPEM(): PKCS#1 PEM) for
// example.org with selector sel, and the DNS record of the key. // example.org with selector sel, and the DNS record of the key.
type signFixture struct { type signFixture struct {
dir, table, keysDir string dir, table, keysDir string
@@ -35,15 +35,15 @@ func newSignFixture(t *testing.T) signFixture {
t.Fatal(err) t.Fatal(err)
} }
keyPath := filepath.Join(keysDir, "example.org", "sel.private") keyPath := filepath.Join(keysDir, "example.org", "sel.private")
if err := os.MkdirAll(filepath.Dir(keyPath), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.WriteFile(keyPath, pemBytes, 0o640); err != nil { if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
table := filepath.Join(dir, "KeyTable") table := filepath.Join(dir, "selectors")
if err := os.WriteFile(table, []byte("example.org example.org:sel:"+keyPath+"\n"), 0o640); err != nil { if err := os.WriteFile(table, []byte("example.org sel\n"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey) pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
@@ -247,7 +247,7 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
}{ }{
{"a login with no domain", "stranger", nil}, {"a login with no domain", "stranger", nil},
{"no line for the domain", "app1", func(t *testing.T, f signFixture) { {"no line for the domain", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("other.example other.example:sel:"+filepath.Join(f.keysDir, "other.example", "sel.private")+"\n"), 0o640); err != nil { if err := os.WriteFile(f.table, []byte("other.example sel\n"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}}, }},
@@ -261,12 +261,23 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
if err := os.Rename(filepath.Join(f.keysDir, "example.org", "sel.private"), outside); err != nil { if err := os.Rename(filepath.Join(f.keysDir, "example.org", "sel.private"), outside); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(f.table, []byte("example.org example.org:sel:"+outside+"\n"), 0o640); err != nil { if err := os.WriteFile(f.table, []byte("example.org ../../sel\n"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}}, }},
{"a key table that is not one", "app1", func(t *testing.T, f signFixture) { {"a name of dots", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o640); err != nil { if err := os.WriteFile(f.table, []byte("example.org sel\n.. sel\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
{"a selector table that is not one", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
{"an old KeyTable line", "app1", func(t *testing.T, f signFixture) {
line := "example.org example.org:sel:" + filepath.Join(f.keysDir, "example.org", "sel.private") + "\n"
if err := os.WriteFile(f.table, []byte(line), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}}, }},
+6 -10
View File
@@ -1,15 +1,11 @@
# From: lines that name another sender of this server, {other}, beside or in # From: lines that name another sender of this server, {other}, beside or in
# place of the application's own address at {domain} (review of audit stage # place of the application's own address at {domain} (review of audit stage
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a # 2). None may pass as {other}: the check passes a line only when a mail
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM # program reads it as one address at {domain} - {other} in a quoted display
# reads at {domain} - {other} in a display name, a comment or a quoted local # name or a comment, or inside a quoted local part - and refuses the rest.
# part - and refuses the rest. The unit test # The unit test (internal/milter/sender_test.go) wants every line it passes
# (internal/milter/sender_test.go) wants every line it passes read at # read so. {OTHER} is {other} in capitals; {other_qp} is {other} with each dot
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants # escaped as a quoted pair. One Go-quoted string per line.
# nothing signed for {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
# e2e on every run.
"b@{other}" "b@{other}"
"<b@{other}>" "<b@{other}>"
"\"a@{domain}\"@{other}" "\"a@{domain}\"@{other}"
+5 -8
View File
@@ -1,15 +1,12 @@
# From: lines the sender check lets through for an application of {domain} # From: lines the sender check lets through for an application of {domain}
# (audit 2026-10-12, stage 2: which key OpenDKIM signs with, seen and not # (audit 2026-10-12, stage 2). One Go-quoted string per line; {domain} is the
# reasoned). One Go-quoted string per line; {domain} is the application's # application's domain. Two tests read this file: the check's unit test
# domain. Two tests read this file: the check's unit test
# (internal/milter/sender_test.go) wants each line passed, and the e2e # (internal/milter/sender_test.go) wants each line passed, and the e2e
# (test/e2e/dkim_from_test.go) sends each one and wants the message signed # (test/e2e/dkim_from_test.go) sends each one and wants the message signed
# for {domain} and nothing else. A line added here is run by both. {DOMAIN} # for {domain} - the signer's key is the login's - with a signature that
# validates over these headers. A line added here is run by both. {DOMAIN}
# is {domain} in capitals; {other} is another sender of the server, which a # is {domain} in capitals; {other} is another sender of the server, which a
# line here names in a way the check's scan does not see (a space after the # line here names inside a quoted display name - passed by design.
# @) - passed by design, and so signed for {domain} alone.
# The lines are composed by hand; what OpenDKIM signs each with is measured,
# by the e2e on every run.
"a@{domain}" "a@{domain}"
"<a@{domain}>" "<a@{domain}>"
"\"The Shop\" <Orders@{domain}>" "\"The Shop\" <Orders@{domain}>"
-56
View File
@@ -1,56 +0,0 @@
/*
* Differential harness for internal/milter/mailparse.go: OpenDKIM's own
* dkim_mail_parse() (libopendkim/dkim-mailparse.c of the opendkim source the
* image runs, compiled in by #include), fed one hex-encoded line per input
* line. Output per line: the error, then the local part and the domain, each
* "-" when NULL and "=" followed by its bytes in hex otherwise.
*
* test/upstream/mailparse-diff.sh <opendkim source dir>
*
* builds it in debian:trixie and runs TestMailParseMatchesOpenDKIM with it.
*/
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "dkim-mailparse.c"
static int hexval(int c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static void put(const unsigned char *s)
{
if (s == NULL) {
fputs(" -", stdout);
return;
}
fputs(" =", stdout);
for (; *s != '\0'; s++)
printf("%02x", *s);
}
int main(void)
{
static char hex[1 << 20];
static unsigned char buf[1 << 19];
while (fgets(hex, sizeof hex, stdin) != NULL) {
size_t n = strcspn(hex, "\r\n"), i;
unsigned char *user, *domain;
int err;
for (i = 0; i + 1 < n; i += 2)
buf[i / 2] = (unsigned char) (hexval(hex[i]) << 4 | hexval(hex[i + 1]));
buf[n / 2] = '\0';
err = dkim_mail_parse(buf, &user, &domain);
printf("%d", err);
put(user);
put(domain);
putchar('\n');
}
return 0;
}
+1 -1
View File
@@ -20,7 +20,7 @@ const (
// Mailer hands notifications to Postfix by local submission, as // Mailer hands notifications to Postfix by local submission, as
// postmaster@<hostname>. They are the panel's own mail: they pass no SASL // postmaster@<hostname>. They are the panel's own mail: they pass no SASL
// login, are not rows of the send log and count against no rate limit. // login, are not rows of the send log and count against no rate limit.
// Postfix signs them through OpenDKIM with the key of the server's own name // The panel's signer signs them with the key of the server's own name
// (non_smtpd_milters in build/postfix-config.sh, domain.Service.SetServerName). // (non_smtpd_milters in build/postfix-config.sh, domain.Service.SetServerName).
type Mailer struct { type Mailer struct {
hostname string hostname string
+1 -1
View File
@@ -74,7 +74,7 @@ func Open(path string) (*Store, error) {
// keepPrivate makes the database file and its WAL and shared-memory files // keepPrivate makes the database file and its WAL and shared-memory files
// the panel's alone (0600). They hold the panel users' password hashes and // the panel's alone (0600). They hold the panel users' password hashes and
// the session token hashes, and /data is traversable by every user of the // the session token hashes, and /data is traversable by every user of the
// container (entrypoint.sh) - Postfix and OpenDKIM among them. SQLite // container (entrypoint.sh) - Postfix among them. SQLite
// creates a file with 0644 less the umask, and the panel sets no umask; the // creates a file with 0644 less the umask, and the panel sets no umask; the
// -wal and -shm files take the database file's mode when they are created // -wal and -shm files take the database file's mode when they are created
// (SQLite 3.53.4 as modernc.org/sqlite v1.60.1 builds it, // (SQLite 3.53.4 as modernc.org/sqlite v1.60.1 builds it,
-21
View File
@@ -1,21 +0,0 @@
// Package supervisor provides helpers for controlling processes through
// supervisord's control socket (architecture.md, security.md).
package supervisor
import (
"fmt"
"os/exec"
"strings"
)
const confPath = "/etc/supervisor/supervisord.conf"
// Signal sends a signal to a supervised program via `supervisorctl signal`.
func Signal(sig, program string) error {
cmd := exec.Command("supervisorctl", "-c", confPath, "signal", sig, program)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("supervisor signal %s %s: %w: %s", sig, program, err, strings.TrimSpace(string(out)))
}
return nil
}
-146
View File
@@ -1,146 +0,0 @@
// Package syslogrelay passes what programs in the container send to syslog(3)
// on to standard output, where supervisord takes it into the container's log.
//
// OpenDKIM logs through syslog(3) and nothing else (OpenDKIM 2.11.0~beta2,
// opendkim/opendkim.conf.5.in: Syslog, SyslogFacility and SyslogName are the
// only logging options; opendkim/opendkim.c calls openlog() and syslog()).
// syslog(3) sends each message as a datagram to /dev/log, and in the container
// nothing listened there: OpenDKIM's errors - a key it cannot read, a table it
// cannot open - went nowhere (audit 2026-10-12, L30). Postfix is not among the
// senders: it logs through its own postlogd (build/postfix-config.sh).
package syslogrelay
import (
"bytes"
"fmt"
"io"
"net"
"os"
"regexp"
"strconv"
"time"
)
// stamp is the BSD syslog timestamp that follows the priority, "Oct 15
// 09:00:00 " (RFC 3164 § 4.1.2: "Mmm dd hh:mm:ss", the day padded with a
// space). The container's log has times of its own.
var stamp = regexp.MustCompile(`^[A-Z][a-z]{2} [ 0-9][0-9] [0-9]{2}:[0-9]{2}:[0-9]{2} `)
// Prefix begins every relayed line. The socket takes datagrams from any user
// in the container, so a relayed line must not pass for one of the panel's
// or supervisord's own (security review of audit stage 5).
const Prefix = "syslog: "
// Line turns one datagram into the line written out: the priority and the
// timestamp taken off, trailing newlines too; what remains is "ident[pid]:
// message". A severity of error or worse (RFC 3164 § 4.1.1: the priority is
// facility*8 + severity, and 0-3 are emergency to error) is said in front. A
// control character within it - a newline that would start a line of its own
// - is written as \xNN.
func Line(datagram []byte) string {
b := bytes.TrimRight(datagram, "\r\n\x00")
severity := -1
if len(b) > 2 && b[0] == '<' {
if end := bytes.IndexByte(b, '>'); end > 1 && end <= 4 {
pri := 0
ok := true
for _, c := range b[1:end] {
if c < '0' || c > '9' {
ok = false
break
}
pri = pri*10 + int(c-'0')
}
if ok {
severity = pri % 8
b = b[end+1:]
}
}
}
if loc := stamp.FindIndex(b); loc != nil {
b = b[loc[1]:]
}
var out bytes.Buffer
out.WriteString(Prefix)
if severity >= 0 && severity <= 3 {
out.WriteString("error: ")
}
for _, c := range b {
if c < 0x20 || c == 0x7f {
fmt.Fprintf(&out, `\x%02x`, c)
continue
}
out.WriteByte(c)
}
return out.String()
}
// The relay keeps reading whatever it can write: a datagram is read at once
// and queued, and dropped when the queue is full or more than ratePerSecond
// came in the last second. A sender to a unix datagram socket waits while the
// receiver's queue is full, so a relay that stopped reading would have held
// OpenDKIM in syslog(3) - with mail waiting on it (review of audit stage 5) -
// and a flood would have filled the container's log.
const (
queueLen = 1024
ratePerSecond = 200
)
// Run listens on a datagram socket at path, which anyone in the container may
// write to, and writes each message to w as a line until the socket fails.
// /dev/log is a link to path (build/entrypoint.sh). What it drops it says,
// once a second at most.
func Run(path string, w io.Writer) error {
_ = os.Remove(path)
conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
if err != nil {
return fmt.Errorf("syslog relay: listen on %s: %w", path, err)
}
defer conn.Close()
// OpenDKIM runs as its own user; the socket is the container's local log,
// as /dev/log is on a host.
if err := os.Chmod(path, 0o666); err != nil {
return fmt.Errorf("syslog relay: %w", err)
}
lines := make(chan string, queueLen)
go func() {
for l := range lines {
fmt.Fprintln(w, l)
}
}()
defer close(lines)
buf := make([]byte, 64<<10)
var second time.Time
inSecond, dropped := 0, 0
for {
n, _, err := conn.ReadFromUnix(buf)
if err != nil {
return fmt.Errorf("syslog relay: read: %w", err)
}
now := time.Now()
if now.Sub(second) >= time.Second {
if dropped > 0 {
select {
case lines <- Prefix + "relay: dropped " + strconv.Itoa(dropped) + " lines - more than " + strconv.Itoa(ratePerSecond) + " a second, or the log was not taking them":
default:
}
}
second, inSecond, dropped = now, 0, 0
}
inSecond++
if n == 0 {
continue
}
if inSecond > ratePerSecond {
dropped++
continue
}
select {
case lines <- Line(buf[:n]):
default:
dropped++
}
}
}
-110
View File
@@ -1,110 +0,0 @@
package syslogrelay
import (
"bytes"
"net"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestLine(t *testing.T) {
for in, want := range map[string]string{
"<22>Oct 15 09:00:00 opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)\n": "syslog: opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)",
"<19>Oct 5 09:00:00 opendkim[45]: key retrieval failed": "syslog: error: opendkim[45]: key retrieval failed",
"<20>opendkim[45]: no timestamp": "syslog: opendkim[45]: no timestamp",
"not syslog at all": "syslog: not syslog at all",
"<x>Oct 15 09:00:00 odd": "syslog: <x>Oct 15 09:00:00 odd",
// A line of its own cannot be made: a newline inside is written out.
"<14>opendkim[1]: one\npanel: forged": `syslog: opendkim[1]: one\x0apanel: forged`,
} {
if got := Line([]byte(in)); got != want {
t.Errorf("Line(%q) = %q, want %q", in, got, want)
}
}
}
// syncBuffer is a bytes.Buffer the relay and the test may share.
type syncBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
// startRelay runs the relay on a socket of its own and returns a connection
// to it.
func startRelay(t *testing.T, w *syncBuffer) net.Conn {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("no unixgram sockets on Windows; the container is Linux")
}
path := filepath.Join(t.TempDir(), "log")
go Run(path, w)
var conn net.Conn
var err error
for i := 0; i < 50; i++ {
if conn, err = net.Dial("unixgram", path); err == nil {
t.Cleanup(func() { conn.Close() })
return conn
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal(err)
return nil
}
// What a program sends to the socket comes out as a line.
func TestRunRelaysADatagram(t *testing.T) {
out := &syncBuffer{}
conn := startRelay(t, out)
if _, err := conn.Write([]byte("<19>Oct 15 09:00:00 opendkim[7]: can't load key")); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && !strings.Contains(out.String(), "\n") {
time.Sleep(10 * time.Millisecond)
}
if got := out.String(); got != "syslog: error: opendkim[7]: can't load key\n" {
t.Errorf("relayed %q", got)
}
}
// A flood is cut at the rate, and what was dropped is said.
func TestRunDropsAFlood(t *testing.T) {
out := &syncBuffer{}
conn := startRelay(t, out)
for i := 0; i < ratePerSecond+100; i++ {
if _, err := conn.Write([]byte("<14>flood")); err != nil {
t.Fatal(err)
}
}
time.Sleep(1100 * time.Millisecond)
if _, err := conn.Write([]byte("<14>after")); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && !strings.Contains(out.String(), "syslog: after") {
time.Sleep(10 * time.Millisecond)
}
got := out.String()
if n := strings.Count(got, "syslog: flood\n"); n != ratePerSecond {
t.Errorf("%d flood lines written, want %d", n, ratePerSecond)
}
if !strings.Contains(got, "syslog: relay: dropped 100 lines") {
t.Errorf("the drop was not said:\n%s", got)
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ type Config struct {
TLSCertFile string TLSCertFile string
TLSServedCertFile string TLSServedCertFile string
TLSReloadInterval time.Duration TLSReloadInterval time.Duration
OpenDKIMSocket string SignSocket string
SignLocalSocket string
JournalSocket string JournalSocket string
SenderSocket string SenderSocket string
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel // Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
+3 -3
View File
@@ -56,11 +56,11 @@ func newAppStandAt(t *testing.T, dbPath string) appStand {
// The domain page is rendered with the domain's DKIM record and its DNS // The domain page is rendered with the domain's DKIM record and its DNS
// state: a real key in a temporary directory, and a resolver nobody // state: a real key in a temporary directory, and a resolver nobody
// listens on, so every check answers at once without leaving the machine. // listens on, so every check answers at once without leaving the machine.
odk := domain.NewOpenDKIM(t.TempDir()) keys := domain.NewKeys(t.TempDir())
if _, err := odk.EnsureKey("example.org", "mail"); err != nil { if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
t.Fatalf("EnsureKey: %v", err) t.Fatalf("EnsureKey: %v", err)
} }
h.domains = domain.NewService(h.store, odk, h.apps, "mail") h.domains = domain.NewService(h.store, keys, h.apps, "mail")
h.dns = offlineDNS h.dns = offlineDNS
h.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600 h.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600
var err error var err error
+18 -14
View File
@@ -5,6 +5,8 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -81,13 +83,11 @@ func contains(list []string, want string) bool {
// and its DMARC record says so. // and its DMARC record says so.
func TestNewDomainHasNoReports(t *testing.T) { func TestNewDomainHasNoReports(t *testing.T) {
s, _ := hostedStand(t) s, _ := hostedStand(t)
// A second OpenDKIM that reloads nothing: the stand's has no supervisor. keys := domain.NewKeys(t.TempDir())
odk := domain.NewOpenDKIM(t.TempDir()) if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
odk.SetReloadHook(func() error { return nil })
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail") s.h.domains = domain.NewService(s.h.store, keys, s.h.apps, "mail")
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}}) rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String()) t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String())
@@ -309,11 +309,9 @@ func (failingDMARCMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error {
// not a plain-text page (audit 2026-10-12, L13). // not a plain-text page (audit 2026-10-12, L13).
func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) { func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
s, maps := hostedStand(t) s, maps := hostedStand(t)
// An OpenDKIM that reloads nothing - the stand has no supervisor - and // Keys of its own, and a sampler for the Health page a failure is shown
// a sampler for the Health page a failure is shown on. // on.
odk := domain.NewOpenDKIM(t.TempDir()) s.h.domains = domain.NewService(s.h.store, domain.NewKeys(t.TempDir()), s.h.apps, "mail")
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
s.h.machine = &health.MachineSampler{} s.h.machine = &health.MachineSampler{}
rec := postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{}) rec := postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/health?reloaded=1" { if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/health?reloaded=1" {
@@ -335,14 +333,20 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
// the way to finish, not "internal error" (audit 2026-10-12, L17). // the way to finish, not "internal error" (audit 2026-10-12, L17).
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) { func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
s, _ := hostedStand(t) s, _ := hostedStand(t)
odk := domain.NewOpenDKIM(t.TempDir()) dir := t.TempDir()
odk.SetReloadHook(func() error { return nil }) s.h.domains = domain.NewService(s.h.store, domain.NewKeys(dir), s.h.apps, "mail")
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
d, err := s.h.domains.Add("gone.example") d, err := s.h.domains.Add("gone.example")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
odk.SetReloadHook(func() error { return errors.New("supervisor away") }) // A directory where the selector table goes: it cannot be rewritten.
table := filepath.Join(dir, "selectors")
if err := os.Remove(table); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(table, "x"), 0o700); err != nil {
t.Fatal(err)
}
rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{}) rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{})
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("delete with a failing rebuild = %d", rec.Code) t.Fatalf("delete with a failing rebuild = %d", rec.Code)
+6 -5
View File
@@ -85,7 +85,7 @@ func dashboardFlash(r *http.Request) string {
} }
// HandleAddDomain validates the submitted name, creates the domain (DKIM key + // HandleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to // selector table), and redirects to the domain's page so the DNS record to
// publish is shown (product.md). // publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
_, ok := h.requireGlobal(w, r) _, ok := h.requireGlobal(w, r)
@@ -171,9 +171,10 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
} }
// HandleReload writes again, from the database, every file the panel keeps // HandleReload writes again, from the database, every file the panel keeps
// for the mail path and has OpenDKIM and Postfix re-read them - what the // for the mail path and has Postfix re-read them - what the first start after
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's // a restore does (cmd/panel mailStack.Resync): the DKIM selector table (and
// tables, the sender map, with the inbound relay on its maps and the filter // the keys of deleted domains removed), the sender map, with the inbound
// relay on its maps and the filter
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP // lists, with DMARC reports on their maps (architecture.md § Panel HTTP
// surface). Before 2.5.4 it left the last two out, and said "regenerated" // surface). Before 2.5.4 it left the last two out, and said "regenerated"
// over them (audit 2026-10-12, L13). A failure is a message on Health that // over them (audit 2026-10-12, L13). A failure is a message on Health that
@@ -187,7 +188,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
run func() error run func() error
} }
steps := []step{ steps := []step{
{"OpenDKIM's tables", h.domains.Resync}, {"the DKIM selector table", h.domains.Resync},
{"the sender map", h.apps.Resync}, {"the sender map", h.apps.Resync},
} }
if h.cfg.InboundEnabled && h.inbound != nil { if h.cfg.InboundEnabled && h.inbound != nil {
@@ -16,17 +16,18 @@ import (
// The inbound spam filter's socket is one of the milter sockets on Health and // The inbound spam filter's socket is one of the milter sockets on Health and
// counts in the Milter sockets card of Overview — and only when the filter is on. // counts in the Milter sockets card of Overview — and only when the filter is on.
// socketsServer is a server whose two Postfix milter sockets answer, so that // socketsServer is a server whose Postfix milter sockets answer, so that
// every difference in the Milter sockets card comes from the filter. // every difference in the Milter sockets card comes from the filter.
func socketsServer(t *testing.T) *Handlers { func socketsServer(t *testing.T) *Handlers {
t.Helper() t.Helper()
h, _ := settingsServer(t) h, _ := settingsServer(t)
h.machine = &health.MachineSampler{} h.machine = &health.MachineSampler{}
dir := t.TempDir() dir := t.TempDir()
h.cfg.OpenDKIMSocket = filepath.Join(dir, "opendkim.sock") h.cfg.SignSocket = filepath.Join(dir, "sign.sock")
h.cfg.SignLocalSocket = filepath.Join(dir, "sign-local.sock")
h.cfg.JournalSocket = filepath.Join(dir, "journal.sock") h.cfg.JournalSocket = filepath.Join(dir, "journal.sock")
h.cfg.SenderSocket = filepath.Join(dir, "sender.sock") h.cfg.SenderSocket = filepath.Join(dir, "sender.sock")
for _, path := range []string{h.cfg.OpenDKIMSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} { for _, path := range []string{h.cfg.SignSocket, h.cfg.SignLocalSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
l, err := net.Listen("unix", path) l, err := net.Listen("unix", path)
if err != nil { if err != nil {
t.Skipf("unix sockets unavailable here: %v", err) t.Skipf("unix sockets unavailable here: %v", err)
@@ -56,24 +57,24 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
h := socketsServer(t) h := socketsServer(t)
h.cfg.InboundEnabled = true h.cfg.InboundEnabled = true
h.cfg.InboundAntispamMilter = "" // no filter attached h.cfg.InboundAntispamMilter = "" // no filter attached
if got := socketsOf(t, h); len(got) != 3 { if got := socketsOf(t, h); len(got) != 4 {
t.Fatalf("%d sockets with the filter off, want 3: %+v", len(got), got) t.Fatalf("%d sockets with the filter off, want 4: %+v", len(got), got)
} }
// A milter address with the inbound relay off is not a filter either. // A milter address with the inbound relay off is not a filter either.
h.cfg.InboundEnabled = false h.cfg.InboundEnabled = false
h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1" h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1"
got := socketsOf(t, h) got := socketsOf(t, h)
if len(got) != 3 { if len(got) != 4 {
t.Fatalf("%d sockets with the inbound relay off, want 3: %+v", len(got), got) t.Fatalf("%d sockets with the inbound relay off, want 4: %+v", len(got), got)
} }
card := socketCard(t, h) card := socketCard(t, h)
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, sender-check, send-log" || card.Level != "" { if card.Value != "4 of 4 answering" || card.Sub != "dkim-signer, sender-check, send-log" || card.Level != "" {
t.Errorf("card = %+v", card) t.Errorf("card = %+v", card)
} }
} }
func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) { func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
h := socketsServer(t) h := socketsServer(t)
l, err := net.Listen("tcp", "127.0.0.1:0") l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -84,7 +85,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail" h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
got := socketsOf(t, h) got := socketsOf(t, h)
if len(got) != 4 || got[3].Name != "Spam filter" || got[3].Note != "inbound" || got[3].Path != "inet:"+l.Addr().String() { if len(got) != 5 || got[4].Name != "Spam filter" || got[4].Note != "inbound" || got[4].Path != "inet:"+l.Addr().String() {
t.Fatalf("sockets = %+v", got) t.Fatalf("sockets = %+v", got)
} }
for _, s := range got { for _, s := range got {
@@ -93,7 +94,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
} }
} }
card := socketCard(t, h) card := socketCard(t, h)
if card.Value != "4 of 4 answering" || card.Sub != "incl. the spam filter" || card.Level != "" { if card.Value != "5 of 5 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
t.Errorf("card = %+v", card) t.Errorf("card = %+v", card)
} }
@@ -111,7 +112,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
} }
// A filter that does not answer is a socket that is missing: an error when // A filter that does not answer is a socket that is missing: an error when
// Postfix defers inbound mail while it is down (as for OpenDKIM), a warning when // Postfix defers inbound mail while it is down (as for the signer), a warning when
// it accepts the mail unfiltered (as for the send log). // it accepts the mail unfiltered (as for the send log).
func TestFilterDownIsAMissingSocket(t *testing.T) { func TestFilterDownIsAMissingSocket(t *testing.T) {
h := socketsServer(t) h := socketsServer(t)
@@ -133,10 +134,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
} { } {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
got := socketsOf(t, h) got := socketsOf(t, h)
if len(got) != 4 { if len(got) != 5 {
t.Fatalf("action %q: %d sockets, want 4", tt.action, len(got)) t.Fatalf("action %q: %d sockets, want 5", tt.action, len(got))
} }
f := got[3] f := got[4]
refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail
if f.Present || f.Status != tt.status || f.Detail != refused { if f.Present || f.Status != tt.status || f.Detail != refused {
t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused) t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused)
@@ -147,7 +148,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status) t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
} }
card := socketCard(t, h) card := socketCard(t, h)
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") { if card.Value != "4 of 5 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
t.Errorf("action %q: card = %+v", tt.action, card) t.Errorf("action %q: card = %+v", tt.action, card)
} }
@@ -161,7 +162,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal)) h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
page := rec.Body.String() page := rec.Body.String()
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (3 of 4 answering)`} { for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (4 of 5 answering)`} {
if !strings.Contains(page, want) { if !strings.Contains(page, want) {
t.Errorf("action %q: Overview is missing %q", tt.action, want) t.Errorf("action %q: Overview is missing %q", tt.action, want)
} }
@@ -201,3 +202,19 @@ func TestFilterAddressIsGlobalOnly(t *testing.T) {
func reqAs(path string, p auth.Principal) *http.Request { func reqAs(path string, p auth.Principal) *http.Request {
return auth.RequestWithPrincipal(httptest.NewRequest("GET", path, nil), p) return auth.RequestWithPrincipal(httptest.NewRequest("GET", path, nil), p)
} }
// The signer's two rows are one name on the card, and a row that does not
// answer says which of the two it is.
func TestSignerSocketsOnTheCard(t *testing.T) {
h := socketsServer(t)
h.cfg.SignLocalSocket = filepath.Join(t.TempDir(), "gone.sock")
got := socketsOf(t, h)
if got[0].Name != "dkim-signer" || got[0].Note != "applications" || !got[0].Present ||
got[1].Name != "dkim-signer" || got[1].Note != "notifications" || got[1].Present || got[1].Status != health.StatusError {
t.Fatalf("sockets = %+v", got)
}
card := socketCard(t, h)
if card.Value != "3 of 4 answering" || card.Sub != "dkim-signer · notifications" || card.Level != "fail" {
t.Errorf("card = %+v", card)
}
}
@@ -19,7 +19,7 @@ import (
func healthReading() healthReadings { func healthReading() healthReadings {
c := checksFixture() c := checksFixture()
c.Processes = []health.Process{ c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK}, {Name: "panel", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError}, {Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
} }
c.Cert = health.Certificate{ c.Cert = health.Certificate{
@@ -44,7 +44,7 @@ func healthReading() healthReadings {
Status: health.StatusOK, Status: health.StatusOK,
} }
c.Sockets = []health.Socket{ c.Sockets = []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"}, {Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
{Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."}, {Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."},
} }
c.SocketStatus = health.StatusWarn c.SocketStatus = health.StatusWarn
@@ -73,7 +73,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
h, _ := settingsServer(t) h, _ := settingsServer(t)
out := renderHealth(t, h, healthReading(), "") out := renderHealth(t, h, healthReading(), "")
for _, want := range []string{ for _, want := range []string{
"opendkim", "fatal", "exited too quickly", "panel", "fatal", "exited too quickly",
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC", "mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC",
`action="/server/health/reload"`, `action="/server/health/recheck"`, `action="/server/health/reload"`, `action="/server/health/recheck"`,
`hx-get="/server/health/fragment"`, `hx-get="/server/health/fragment"`,
@@ -208,7 +208,7 @@ func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) { func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
h, _ := settingsServer(t) h, _ := settingsServer(t)
h.machine = &health.MachineSampler{} h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost") h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "selfpost")
h.cfg.Hostname = "mail.example.org" h.cfg.Hostname = "mail.example.org"
d, err := h.store.AddDomain("example.org", "mail") d, err := h.store.AddDomain("example.org", "mail")
if err != nil { if err != nil {
@@ -153,7 +153,7 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
h.dns = dnscheck.New([]string{addr}) h.dns = dnscheck.New([]string{addr})
h.cfg.Hostname = notifyHost h.cfg.Hostname = notifyHost
h.machine = &health.MachineSampler{} h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026") h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "sp2026")
h.domains.SetServerName(notifyHost) h.domains.SetServerName(notifyHost)
mail, err := h.domains.ServerMail() mail, err := h.domains.ServerMail()
if err != nil { if err != nil {
@@ -124,7 +124,7 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
// A domain service that does not know the server's own name, so that // A domain service that does not know the server's own name, so that
// the records of Settings and Health come out as "cannot be read"; // the records of Settings and Health come out as "cannot be read";
// the tests of those records use serverMailStand. // the tests of those records use serverMailStand.
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"), domains: domain.NewService(st, domain.NewKeys(t.TempDir()), nil, "selfpost"),
cfg: Config{SendLogRetentionEnvDefault: 90}, cfg: Config{SendLogRetentionEnvDefault: 90},
}, password }, password
} }
+27 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -346,19 +347,24 @@ func healthCards(c healthReadings) []view.HealthCard {
} }
// The line under the count has to fit a card: the sockets that do not // The line under the count has to fit a card: the sockets that do not
// answer when some do not; otherwise their names, or - with the inbound spam // answer when some do not; otherwise their names - the signer's two once -
// filter among them, when three names no longer fit - that it is included. // or, with the inbound spam filter among them, when three names no longer
// fit, that it is included.
answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false
for _, s := range sockets { for _, s := range sockets {
if s.Present { if s.Present {
answering++ answering++
} else if s.Note != "" && s.Name != filterName {
silent = append(silent, s.Name+" · "+s.Note)
} else { } else {
silent = append(silent, s.Name) silent = append(silent, s.Name)
} }
if s.Note != "" { if s.Name == filterName {
withFilter = true withFilter = true
} }
socketNames = append(socketNames, s.Name) if !slices.Contains(socketNames, s.Name) {
socketNames = append(socketNames, s.Name)
}
} }
socketSub := strings.Join(socketNames, ", ") socketSub := strings.Join(socketNames, ", ")
switch { switch {
@@ -455,7 +461,8 @@ func (h *Handlers) healthChecks() healthReadings {
cert := health.CheckServedCertificate(h.cfg.TLSServedCertFile, h.cfg.TLSCertFile, h.cfg.TLSReloadInterval, time.Now()) cert := health.CheckServedCertificate(h.cfg.TLSServedCertFile, h.cfg.TLSCertFile, h.cfg.TLSReloadInterval, time.Now())
sockets := []health.Socket{ sockets := []health.Socket{
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true), signerSocket(h.cfg.SignSocket, "applications"),
signerSocket(h.cfg.SignLocalSocket, "notifications"),
health.CheckSocket("sender-check", h.cfg.SenderSocket, true), health.CheckSocket("sender-check", h.cfg.SenderSocket, true),
health.CheckSocket("send-log", h.cfg.JournalSocket, false), health.CheckSocket("send-log", h.cfg.JournalSocket, false),
} }
@@ -486,15 +493,27 @@ func (h *Handlers) healthChecks() healthReadings {
} }
} }
// filterName is the inbound spam filter's row among the milter sockets.
const filterName = "Spam filter"
// signerSocket is a row of the DKIM signer's: one socket for what
// applications send, one for the panel's own notifications. Postfix attaches
// both strictly, so either one down defers the mail that goes through it.
func signerSocket(path, note string) health.Socket {
s := health.CheckSocket("dkim-signer", path, true)
s.Note = note
return s
}
// filterSocket is the inbound spam filter's row among the milter sockets. A // filterSocket is the inbound spam filter's row among the milter sockets. A
// filter that is down costs what the server's setting says: with the action // filter that is down costs what the server's setting says: with the action
// tempfail Postfix defers inbound mail (an error, like OpenDKIM), otherwise it // tempfail Postfix defers inbound mail (an error, like the signer), otherwise it
// accepts the mail unfiltered (a warning, like the send log). The address is the // accepts the mail unfiltered (a warning, like the send log). The address is the
// one in INBOUND_ANTISPAM_MILTER as configured; Health and Overview, the only // one in INBOUND_ANTISPAM_MILTER as configured; Health and Overview, the only
// pages that show it, are for the global administrator. // pages that show it, are for the global administrator.
func filterSocket(milter, action string) health.Socket { func filterSocket(milter, action string) health.Socket {
tempfail := action == "tempfail" tempfail := action == "tempfail"
s := health.CheckMilter("Spam filter", milter, tempfail) s := health.CheckMilter(filterName, milter, tempfail)
s.Note = "inbound" s.Note = "inbound"
switch { switch {
case s.Status != health.StatusError && s.Status != health.StatusWarn: case s.Status != health.StatusError && s.Status != health.StatusWarn:
@@ -519,7 +538,7 @@ func queueSummary(out string) string {
func statusFlash(r *http.Request) string { func statusFlash(r *http.Request) string {
switch { switch {
case r.URL.Query().Get("reloaded") != "": case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database - OpenDKIM's tables, every map the panel writes for Postfix, the filter lists - and OpenDKIM and Postfix have re-read it." return "Configuration regenerated from the database - the DKIM selector table, every map the panel writes for Postfix, the filter lists - and Postfix has re-read it."
case r.URL.Query().Get("rechecked") != "": case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked." return "DNS re-checked."
default: default:
@@ -13,7 +13,7 @@ import (
func checksFixture() healthReadings { func checksFixture() healthReadings {
return healthReadings{ return healthReadings{
Processes: []health.Process{ Processes: []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK}, {Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Status: health.StatusError}, {Name: "postfix", State: "FATAL", Status: health.StatusError},
}, },
ProcessError: false, ProcessError: false,
@@ -24,7 +24,7 @@ func checksFixture() healthReadings {
Machine: health.Machine{Status: health.StatusUnknown}, Machine: health.Machine{Status: health.StatusUnknown},
Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."}, Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
Sockets: []health.Socket{ Sockets: []health.Socket{
{Name: "OpenDKIM", Present: true, Status: health.StatusOK}, {Name: "dkim-signer", Present: true, Status: health.StatusOK},
{Name: "send-log", Present: false, Status: health.StatusWarn}, {Name: "send-log", Present: false, Status: health.StatusWarn},
}, },
SocketStatus: health.StatusWarn, SocketStatus: health.StatusWarn,
@@ -87,7 +87,7 @@ func TestQueueCardValue(t *testing.T) {
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) { func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
c := checksFixture() c := checksFixture()
c.Processes = []health.Process{ c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK}, {Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "RUNNING", Status: health.StatusOK}, {Name: "postfix", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true}, {Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
} }
@@ -98,7 +98,7 @@ func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
} }
c.Processes = []health.Process{ c.Processes = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK}, {Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true}, {Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
} }
card = healthCards(c)[1] card = healthCards(c)[1]
+4 -4
View File
@@ -52,15 +52,15 @@ func isASCIILetterOrDigit(r rune) bool {
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
// NormalizeDomain lower-cases and trims a domain name. Domain names are // NormalizeDomain lower-cases and trims a domain name. Domain names are
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical // case-insensitive, and the DKIM selector table and keys use the canonical
// lower-case form, so we normalise before both validation and storage. // lower-case form, so we normalise before both validation and storage.
func NormalizeDomain(name string) string { func NormalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name)) return strings.ToLower(strings.TrimSpace(name))
} }
// Domain enforces a strict server-side whitelist for sending-domain names // Domain enforces a strict server-side whitelist for sending-domain names
// (security.md). The result is safe to write verbatim into the OpenDKIM // (security.md). The result is safe to write verbatim into the DKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only // selector table and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label // lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with NormalizeDomain. // shape. Input must already be normalised with NormalizeDomain.
func Domain(name string) error { func Domain(name string) error {
@@ -85,7 +85,7 @@ func Domain(name string) error {
// Selector checks a DKIM selector: one or more DNS labels as Domain allows // Selector checks a DKIM selector: one or more DNS labels as Domain allows
// them, at most 63 characters in all. The panel's own selector is fixed in // them, at most 63 characters in all. The panel's own selector is fixed in
// its configuration; this is for one read from a domain export file, which // its configuration; this is for one read from a domain export file, which
// goes into OpenDKIM's tables, a key file's name and every signature's s= // goes into the selector table, a key file's name and every signature's s=
// (review of audit stage 2). // (review of audit stage 2).
func Selector(s string) error { func Selector(s string) error {
if s == "" { if s == "" {
+10 -10
View File
@@ -65,10 +65,10 @@ func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
func overviewFixture() *Overview { func overviewFixture() *Overview {
cards := []HealthCard{ cards := []HealthCard{
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health"}, {Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health"},
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"}, {Name: "Processes", Value: "4 of 4 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn}, {Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"}, {Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health"}, {Name: "Milter sockets", Value: "5 of 5 answering", Sub: "incl. the spam filter", Icon: "ti-plug-connected", Href: "/server/health"},
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health"}, {Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health"},
} }
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true) o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
@@ -87,8 +87,8 @@ func overviewFixture() *Overview {
return o return o
} }
// healthFixture is the mockup's Health: a healthy machine and five processes, // healthFixture is the mockup's Health: a healthy machine and four processes,
// a certificate close to expiry, the two milter sockets and a good reverse // a certificate close to expiry, the milter sockets and a good reverse
// record. refresh renders it as the polled fragment. // record. refresh renders it as the polled fragment.
func healthFixture(refresh bool) *Health { func healthFixture(refresh bool) *Health {
h := NewHealth(admin(), "") h := NewHealth(admin(), "")
@@ -107,7 +107,7 @@ func healthFixture(refresh bool) *Health {
{Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}}, {Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}},
} }
h.Processes.End = Verdict("ok") h.Processes.End = Verdict("ok")
for i, name := range []string{"postfix", "opendkim", "journal-milter", "panel", "dmarc-ingest"} { for i, name := range []string{"postfix", "journal-milter", "panel", "dmarc-ingest"} {
h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"}, h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"},
Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"}) Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"})
} }
@@ -119,7 +119,8 @@ func healthFixture(refresh bool) *Health {
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
h.Sockets.End = Verdict("ok") h.Sockets.End = Verdict("ok")
h.SocketRows = []SocketRow{ h.SocketRows = []SocketRow{
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}}, {Name: "dkim-signer", Note: "applications", Path: "unix:/run/selfpost/sign.sock", State: Tag{Status: "ok"}},
{Name: "dkim-signer", Note: "notifications", Path: "unix:/run/selfpost/sign-local.sock", State: Tag{Status: "ok"}},
{Name: "sender-check", Path: "unix:/run/selfpost/sender.sock", State: Tag{Status: "ok"}}, {Name: "sender-check", Path: "unix:/run/selfpost/sender.sock", State: Tag{Status: "ok"}},
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}}, {Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
} }
@@ -271,7 +272,7 @@ func outLogFixture() *OutLog {
} }
// outMessageFixture is the mockup's message page: a deferred alert, with the // outMessageFixture is the mockup's message page: a deferred alert, with the
// four lines Postfix and OpenDKIM wrote about it. // four lines Postfix wrote about it.
func outMessageFixture() *OutMessage { func outMessageFixture() *OutMessage {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC) at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ p := NewOutMessage(admin(), MessageInput{
@@ -290,7 +291,7 @@ func outMessageFixture() *OutMessage {
return p.WithDeliveryLog([]LogLine{ return p.WithDeliveryLog([]LogLine{
{Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"}, {Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"},
{Time: "13:58:07", Text: "postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>"}, {Time: "13:58:07", Text: "postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>"},
{Time: "13:58:07", Text: "opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)"}, {Time: "13:58:07", Text: "postfix/qmgr[2061]: 4XcB7k2Jm9z1: from=<alerts@example.org>, size=2214, nrcpt=1 (queue active)"},
{Time: "13:58:09", Level: "warn", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)"}, {Time: "13:58:09", Level: "warn", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)"},
}, "") }, "")
} }
@@ -389,12 +390,11 @@ func outQueueFixture() *OutQueue {
return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false) return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
} }
// systemLogFixture is the mockup's System log: ten lines of mail.log, as the // systemLogFixture is the mockup's System log: nine lines of mail.log, as the
// handler reads them (oldest first), with the deferrals in amber and the // handler reads them (oldest first), with the deferrals in amber and the
// refusals and the bounce in red. // refusals and the bounce in red.
func systemLogFixture() *SystemLog { func systemLogFixture() *SystemLog {
return NewSystemLog(admin(), []LogLine{ return NewSystemLog(admin(), []LogLine{
{Time: "Sep 21 13:00:00", Text: "opendkim[39]: key table reloaded (3 domains)"},
{Time: "Sep 21 13:40:02", Text: "journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM", Level: "warn"}, {Time: "Sep 21 13:40:02", Text: "journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM", Level: "warn"},
{Time: "Sep 21 13:41:19", Text: "postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)", Level: "error"}, {Time: "Sep 21 13:41:19", Text: "postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)", Level: "error"},
{Time: "Sep 21 13:44:02", Text: "postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)", Level: "warn"}, {Time: "Sep 21 13:44:02", Text: "postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)", Level: "warn"},
+2 -2
View File
@@ -167,9 +167,9 @@ var outboundTopics = []HelpTopic{
}}, }},
{ID: HelpSenderCheck, Title: "Sender check", Body: []Text{ {ID: HelpSenderCheck, Title: "Sender check", Body: []Text{
Rich("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 ", Rich("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:"), " 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:"), " line, the one a mail program shows and the one DMARC holds the DKIM signature to, 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:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ", Code("From:"), " - 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"), ". 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.", Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name 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 sending, under this server's signature, as another domain or another application.",
Br(), Br(), 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."), "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."),
}}, }},
+1 -1
View File
@@ -797,6 +797,6 @@ func NewOutDomainDelete(m Meta, id int64, name string, logins []string) *OutDoma
item = append(item, Inline{Text: ") with their credentials and sender bindings;"}) item = append(item, Inline{Text: ") with their credentials and sender bindings;"})
p.Consequences = append(p.Consequences, item) p.Consequences = append(p.Consequences, item)
} }
p.Consequences = append(p.Consequences, Rich("reload OpenDKIM so the domain is no longer signed.")) p.Consequences = append(p.Consequences, Rich("stop signing mail as the domain."))
return p return p
} }
+1 -1
View File
@@ -213,7 +213,7 @@ func TestStepLevelAndLogLineLevel(t *testing.T) {
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error", "postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error", "postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
"postfix/smtp[1]: warning: TLS library problem": "warn", "postfix/smtp[1]: warning: TLS library problem": "warn",
"opendkim[1]: Q1: DKIM-Signature field added": "", "postfix/qmgr[1]: Q1: from=<a@b>, size=1, nrcpt=1 (queue active)": "",
"postfix/cleanup[1]: Q1: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: x - no": "error", "postfix/cleanup[1]: Q1: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: x - no": "error",
"postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable": "warn", "postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable": "warn",
"postfix/smtpd[1]: NOQUEUE: reject: RCPT from unknown[2001:db8::1]: 554 5.7.1 <a@b>: Relay access denied": "error", "postfix/smtpd[1]: NOQUEUE: reject: RCPT from unknown[2001:db8::1]: 554 5.7.1 <a@b>: Relay access denied": "error",
+2 -2
View File
@@ -434,7 +434,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
`<h1 class="title is-3">Delete example.org</h1>`, `<div class="box sp-danger-zone">`, `<h2>This cannot be undone</h2>`, `<h1 class="title is-3">Delete example.org</h1>`, `<div class="box sp-danger-zone">`, `<h2>This cannot be undone</h2>`,
`Deleting <strong>example.org</strong> will:`, `permanently delete its DKIM signing key;`, `Deleting <strong>example.org</strong> will:`, `permanently delete its DKIM signing key;`,
`delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;`, `delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;`,
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`, `stop signing mail as the domain.`, `Inbound domains are not touched.`,
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`, `<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
`href="/outbound/domains/1">Keep it</a>`, `href="/outbound/domains/1">Keep it</a>`,
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings">Export the domain</a>`, `<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings">Export the domain</a>`,
@@ -443,7 +443,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
// No applications: nothing to say about them. // No applications: nothing to say about them.
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", nil)) out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", nil))
pageLacks(t, "Delete", out, `applications`, `credentials`) pageLacks(t, "Delete", out, `applications`, `credentials`)
pageHas(t, "Delete", out, `permanently delete its DKIM signing key;`, `reload OpenDKIM`) pageHas(t, "Delete", out, `permanently delete its DKIM signing key;`, `stop signing mail as the domain.`)
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", []string{"only"})) out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", []string{"only"}))
pageHas(t, "Delete", out, `<strong>its application</strong> (<span class="sp-mono">only</span>)`) pageHas(t, "Delete", out, `<strong>its application</strong> (<span class="sp-mono">only</span>)`)
} }
+4 -4
View File
@@ -155,7 +155,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`, `↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
`pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`, `pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`,
`2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`, `2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
`inet:127.0.0.1:8891`, `→ 203.0.113.25<br>← mail.example.org`, `dkim-signer <span class="sp-muted">· notifications</span>`, `unix:/run/selfpost/sign-local.sock`, `→ 203.0.113.25<br>← mail.example.org`,
`hx-get="/server/health/fragment"`, `hx-get="/server/health/fragment"`,
) )
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`) pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
@@ -252,8 +252,8 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
h.ProcessError = true h.ProcessError = true
h.ProcessRows = nil h.ProcessRows = nil
h.CertFacts, h.CertDetail, h.CertProblem = nil, "No certificate path is configured (TLS_CERT_FILE).", true h.CertFacts, h.CertDetail, h.CertProblem = nil, "No certificate path is configured (TLS_CERT_FILE).", true
h.SocketRows = []SocketRow{{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", State: Tag{Status: "error"}, h.SocketRows = []SocketRow{{Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", State: Tag{Status: "error"},
Detail: "OpenDKIM socket missing: mail cannot leave."}} Detail: "dkim-signer socket missing: mail cannot leave."}}
h.SocketRows = append(h.SocketRows, SocketRow{Name: "rspamd", Note: "inbound spam filter", Path: "inet:antispam:11332", State: Tag{Status: "ok"}}) h.SocketRows = append(h.SocketRows, SocketRow{Name: "rspamd", Note: "inbound spam filter", Path: "inet:antispam:11332", State: Tag{Status: "ok"}})
h.HostFacts = []Fact{{Label: "Hostname", Value: Plain("(SELFPOST_HOSTNAME is not set)"), Mono: true}} h.HostFacts = []Fact{{Label: "Hostname", Value: Plain("(SELFPOST_HOSTNAME is not set)"), Mono: true}}
h.Flash = &Flash{Text: Plain("DNS re-checked.")} h.Flash = &Flash{Text: Plain("DNS re-checked.")}
@@ -261,7 +261,7 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
pageHas(t, "Health", out, pageHas(t, "Health", out,
`Could not ask supervisord for the process list.`, `Could not ask supervisord for the process list.`,
`No certificate path is configured`, `No certificate path is configured`,
`<span class="tag is-danger is-light">error</span>`, `OpenDKIM socket missing: mail cannot leave.`, `<span class="tag is-danger is-light">error</span>`, `dkim-signer socket missing: mail cannot leave.`,
`SELFPOST_HOSTNAME is not set`, `rspamd <span class="sp-muted">· inbound spam filter</span>`, `SELFPOST_HOSTNAME is not set`, `rspamd <span class="sp-muted">· inbound spam filter</span>`,
`<div class="notification is-success is-light">`, `DNS re-checked.`, `<div class="notification is-success is-light">`, `DNS re-checked.`,
) )
+1 -1
View File
@@ -51,7 +51,7 @@ func NewSystemLog(m Meta, lines []LogLine, errText string) *SystemLog {
Head: Head{ Head: Head{
Kicker: "Server", Kicker: "Server",
Title: "System log", Title: "System log",
Lead: Rich("The tail of ", Code("mail.log"), " — Postfix, OpenDKIM and the milters. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."), Lead: Rich("The tail of ", Code("mail.log"), ", the log Postfix writes. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
}, },
Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment", Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment",
End: Plain("Last 200 lines · refreshes on its own")}, End: Plain("Last 200 lines · refreshes on its own")},
+1 -1
View File
@@ -18,7 +18,7 @@ func TestSystemLogShowsNewestFirstAndPollsItsBox(t *testing.T) {
`<span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]`, `<span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]`,
`<span class="sp-w">postfix/smtp[2123]`, `<span class="sp-e">postfix/smtpd[2119]`, `<span class="sp-w">postfix/smtp[2123]`, `<span class="sp-e">postfix/smtpd[2119]`,
) )
if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "opendkim[39]") { if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "journal-milter[40]") {
t.Error("the log is not shown newest line first") t.Error("the log is not shown newest line first")
} }
// What a line says is escaped, not interpreted. // What a line says is escaped, not interpreted.
+1 -1
View File
@@ -15,7 +15,7 @@
{{template "health_mail" .}} {{template "health_mail" .}}
{{template "box_open" .Configuration}} {{template "box_open" .Configuration}}
{{template "body_open"}} {{template "body_open"}}
<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> <p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. 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>
{{template "action" .Reload}} {{template "action" .Reload}}
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
+11 -8
View File
@@ -66,12 +66,14 @@ type Config struct {
TLSCertFile string TLSCertFile string
TLSServedCertFile string TLSServedCertFile string
TLSReloadInterval time.Duration TLSReloadInterval time.Duration
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix // SignSocket and SignLocalSocket are the DKIM signer's two milter
// connects to. The status page dials them: the first is required for mail // sockets, applications' mail and the panel's own; JournalSocket is the
// to leave at all (OpenDKIM runs with default_action=tempfail), the second // journal-milter's. The status page dials them: the signer's are required
// only for the send log (the journal-milter fails open). // for mail to leave at all (default_action=tempfail), the journal's only
OpenDKIMSocket string // for the send log (the journal-milter fails open).
JournalSocket string SignSocket string
SignLocalSocket string
JournalSocket string
// SenderSocket is the sender check's socket, attached strictly. // SenderSocket is the sender check's socket, attached strictly.
SenderSocket string SenderSocket string
// SessionIdleDays is the sliding inactivity window after which a login // SessionIdleDays is the sliding inactivity window after which a login
@@ -118,7 +120,7 @@ type Server struct {
// New builds the panel server. setupTokenPath is where the current setup token // New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (security.md); domains is the sending-domain service // is mirrored on disk (security.md); domains is the sending-domain service
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // that owns DKIM keys and the selector table (architecture.md § DKIM signing);
// apps owns application SASL accounts and the Postfix sender map // apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path). // (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) { func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
@@ -145,7 +147,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
TLSCertFile: cfg.TLSCertFile, TLSCertFile: cfg.TLSCertFile,
TLSServedCertFile: cfg.TLSServedCertFile, TLSServedCertFile: cfg.TLSServedCertFile,
TLSReloadInterval: cfg.TLSReloadInterval, TLSReloadInterval: cfg.TLSReloadInterval,
OpenDKIMSocket: cfg.OpenDKIMSocket, SignSocket: cfg.SignSocket,
SignLocalSocket: cfg.SignLocalSocket,
JournalSocket: cfg.JournalSocket, JournalSocket: cfg.JournalSocket,
SenderSocket: cfg.SenderSocket, SenderSocket: cfg.SenderSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
+1 -1
View File
@@ -103,7 +103,7 @@ services:
# Sink-MX: accepts any inbound mail and dumps each transaction to its own # Sink-MX: accepts any inbound mail and dumps each transaction to its own
# file under ./mail-stage, which the harness reads directly to check the # file under ./mail-stage, which the harness reads directly to check the
# DKIM-Signature header Postfix/OpenDKIM produced. # DKIM-Signature header the panel's signer produced.
sink: sink:
build: build:
context: ../sink context: ../sink
+6 -50
View File
@@ -41,13 +41,12 @@ func fromLines(repoRoot, file, domain, other string) ([]string, error) {
return out, nil return out, nil
} }
// testSignedForItsDomainOnly: the sender check and OpenDKIM read From: with // testSignedForItsDomainOnly: every From: line the check passes for an
// two parsers, and that they agree was argued, never run (audit 2026-10-12, // application is sent as that application, and what arrives must be signed
// stage 2). Every From: line the check passes for an application is sent as // for its domain - with a signature that validates over these headers - and
// that application, and what arrives must be signed for its domain - with a // for no other (audit 2026-10-12, stage 2). Until 2.8.0 OpenDKIM chose the
// signature that validates - and for no other: a line the check reads as the // key by the line and this was the check that the two read it alike; the
// application's own and OpenDKIM as another sender's would be signed with // panel's signer takes the login's key, and the run holds it to that.
// that sender's key.
func testSignedForItsDomainOnly(t *testing.T, sc *scenario) { func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-passed.txt", senderDomain, otherDomain) lines, err := fromLines(h.repoRoot, "from-lines-passed.txt", senderDomain, otherDomain)
if err != nil { if err != nil {
@@ -89,49 +88,6 @@ func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
} }
} }
// testNeverSignedForAnother: no line that names another sender of the server
// arrives signed with that sender's key (review of audit stage 2). Since stage
// 2c the check reads a line as OpenDKIM does: one whose address is the
// application's passes, wherever the other sender's name is written, and must
// arrive signed for the application's domain; the rest are refused.
func testNeverSignedForAnother(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-other.txt", senderDomain, otherDomain)
if err != nil {
t.Fatal(err)
}
for _, line := range lines {
token := uniqueToken("dkimother")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, headerFrom: line, to: recipient,
subject: "e2e " + token, body: "which key signs this",
})
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
continue
}
if !res.ok() {
t.Errorf("From: %q: neither passed nor refused by the check: %v", line, res.firstErr())
continue
}
raw, err := findSinkMessage(h.stageDir, token, 15*time.Second)
if err != nil {
t.Errorf("From: %q passed and never arrived: %v", line, err)
continue
}
verifications, _ := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
own := false
for _, v := range verifications {
if v.Domain == otherDomain {
t.Errorf("From: %q arrived signed for %s", line, otherDomain)
}
own = own || (v.Domain == senderDomain && v.Err == nil)
}
if !own {
t.Errorf("From: %q passed and did not arrive signed for %s: %+v", line, senderDomain, verifications)
}
}
}
// testLoginCaseMatters: SASL compares the login byte for byte, so an // testLoginCaseMatters: SASL compares the login byte for byte, so an
// application's login in other capitals does not authenticate. Every lookup on // application's login in other capitals does not authenticate. Every lookup on
// the mail path is by the exact login and relies on that (review of audit // the mail path is by the exact login and relies on that (review of audit
+5 -4
View File
@@ -44,11 +44,12 @@ func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
return rest return rest
} }
// The panel's user reaches all three sockets through the selfpost group, // The panel's user reaches all four sockets - the signer's two, the
// as Postfix does: three ok rows and no detail under any. // sender check's, the journal's - through the selfpost group, as Postfix
// does: four ok rows and no detail under any.
sockets := box(health(), "sockets") sockets := box(health(), "sockets")
if n := strings.Count(sockets, `<tr><td>`); n != 3 { if n := strings.Count(sockets, `<tr><td>`); n != 4 {
t.Fatalf("Milter sockets has %d rows, want 3:\n%s", n, sockets) t.Fatalf("Milter sockets has %d rows, want 4:\n%s", n, sockets)
} }
if strings.Contains(sockets, `sp-muted sp-small">`) { if strings.Contains(sockets, `sp-muted sp-small">`) {
t.Fatalf("a milter socket is not answering the panel's dial:\n%s", sockets) t.Fatalf("a milter socket is not answering the panel's dial:\n%s", sockets)
+1 -1
View File
@@ -97,7 +97,7 @@ func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, s
_ = exec.Command("docker", "rm", "-f", name).Run() _ = exec.Command("docker", "rm", "-f", name).Run()
defer func() { defer func() {
// Make bind-mounted /data deletable by Go's TempDir cleanup: the panel // Make bind-mounted /data deletable by Go's TempDir cleanup: the panel
// leaves setup-token/db/opendkim owned by container UIDs. // leaves setup-token/db/dkim owned by container UIDs.
_ = exec.Command("docker", "exec", name, "sh", "-c", _ = exec.Command("docker", "exec", name, "sh", "-c",
"chown -R root:root /data && chmod -R a+rwX /data").Run() "chown -R root:root /data && chmod -R a+rwX /data").Run()
_ = exec.Command("docker", "rm", "-f", name).Run() _ = exec.Command("docker", "rm", "-f", name).Run()
+21 -29
View File
@@ -88,42 +88,34 @@ func verifyDKIM(raw []byte, domain string) error {
return nil return nil
} }
// checkBothSigners is the oracle of docs/plans/panel-dkim-signing.md, stage // checkPanelSignature is the oracle of docs/plans/panel-dkim-signing.md: the
// 1: the panel's signer and OpenDKIM both sign (OpenDKIM after the panel in // message carries one DKIM signature, the panel's, for domain - signed as
// the chain, so its signature is the top one - build/postfix-config.sh); // OpenDKIM signed until 2.8.0 (relaxed/simple, rsa-sha256, From over-signed)
// the two must agree on what they sign - the same h= and bh= - and each // - and dkimpy (python3-dkim in the sink image), an implementation that is
// must verify with dkimpy (python3-dkim in the sink image), an // not go-msgauth, the panel's library, verifies it. Stage 1 compared it with
// implementation that is neither go-msgauth, the panel's library, nor // OpenDKIM's on every message before OpenDKIM left. name is the file in the
// OpenDKIM. name is the file in the sink's /mail. // sink's /mail.
func checkBothSigners(s *stack, name string, raw []byte, domain string) error { func checkPanelSignature(s *stack, name string, raw []byte, domain string) error {
msg, err := mail.ReadMessage(bytes.NewReader(raw)) msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil { if err != nil {
return fmt.Errorf("parse the delivered message: %w", err) return fmt.Errorf("parse the delivered message: %w", err)
} }
var sigs []map[string]string sigs := msg.Header["Dkim-Signature"]
for _, v := range msg.Header["Dkim-Signature"] { if len(sigs) != 1 {
tags := dkimTags(v) return fmt.Errorf("%d DKIM signatures, want 1, the panel's", len(sigs))
if tags["d"] == domain { }
sigs = append(sigs, tags) tags := dkimTags(sigs[0])
for tag, want := range map[string]string{"d": domain, "c": "relaxed/simple", "a": "rsa-sha256"} {
if tags[tag] != want {
return fmt.Errorf("the signature has %s=%q, want %q", tag, tags[tag], want)
} }
} }
if len(sigs) != 2 { if h := strings.ToLower(tags["h"]); !strings.HasPrefix(h, "from:") || !strings.HasSuffix(h, ":from") {
return fmt.Errorf("%d DKIM signatures for %s, want 2 (the panel's and OpenDKIM's)", len(sigs), domain) return fmt.Errorf("the signature's h=%q does not sign From and over-sign it", tags["h"])
} }
for _, tag := range []string{"bh", "h", "c", "s", "a"} { out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index 0 < '/mail/%s'", name))
a, b := sigs[0][tag], sigs[1][tag] if err != nil || !strings.Contains(out, "signature ok") {
if tag == "h" { return fmt.Errorf("dkimpy: %v\n%s", err, out)
a, b = strings.ToLower(a), strings.ToLower(b)
}
if a != b {
return fmt.Errorf("the signatures differ in %s=: OpenDKIM %q, the panel %q", tag, a, b)
}
}
for i := range sigs {
out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index %d < '/mail/%s'", i, name))
if err != nil || !strings.Contains(out, "signature ok") {
return fmt.Errorf("dkimpy on signature %d (%s): %v\n%s", i, []string{"OpenDKIM's", "the panel's"}[i], err, out)
}
} }
return nil return nil
} }
+2 -5
View File
@@ -105,7 +105,7 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil { if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := checkOpenDKIMLogs(h); err != nil { if err := checkDKIMTree(h); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := checkPostfixHardening(h); err != nil { if err := checkPostfixHardening(h); err != nil {
@@ -274,7 +274,7 @@ func TestE2E(t *testing.T) {
if err := verifyDKIM(raw, senderDomain); err != nil { if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("DKIM did not verify against the record the panel published: %v", err) t.Fatalf("DKIM did not verify against the record the panel published: %v", err)
} }
if err := checkBothSigners(h, file, raw, senderDomain); err != nil { if err := checkPanelSignature(h, file, raw, senderDomain); err != nil {
t.Fatalf("the panel's signer: %v\n%s", err, raw) t.Fatalf("the panel's signer: %v\n%s", err, raw)
} }
if err := checkReceivedHidden(raw); err != nil { if err := checkReceivedHidden(raw); err != nil {
@@ -333,9 +333,6 @@ func TestE2E(t *testing.T) {
run("refusal_recovered_from_the_log", func(t *testing.T) { run("refusal_recovered_from_the_log", func(t *testing.T) {
testRefusalRecoveredFromTheLog(t, sc) testRefusalRecoveredFromTheLog(t, sc)
}) })
run("lines_naming_another_sender_never_signed_for_it", func(t *testing.T) {
testNeverSignedForAnother(t, sc)
})
run("login_in_other_capitals_does_not_authenticate", func(t *testing.T) { run("login_in_other_capitals_does_not_authenticate", func(t *testing.T) {
testLoginCaseMatters(t, sc) testLoginCaseMatters(t, sc)
}) })
+2 -2
View File
@@ -87,7 +87,7 @@ func testNotificationsSetUp(t *testing.T, sc *scenario) {
// testRateLimitRefusalIsNotified checks the whole path of a notification: the // testRateLimitRefusalIsNotified checks the whole path of a notification: the
// level-2 refusal of the previous step is noticed by the notifier (it looks // level-2 refusal of the previous step is noticed by the notifier (it looks
// every minute), written to the administrator, submitted by the unprivileged // every minute), written to the administrator, submitted by the unprivileged
// panel through sendmail, signed by OpenDKIM with the key of the server's own // panel through sendmail, signed by the panel's signer with the key of the server's own
// name and delivered. It also holds what a notification is not: a row of the // name and delivered. It also holds what a notification is not: a row of the
// Outbound log. That it is said once is for the unit tests to hold // Outbound log. That it is said once is for the unit tests to hold
// (internal/notify): here it would be a minute of waiting for nothing to happen. // (internal/notify): here it would be a minute of waiting for nothing to happen.
@@ -108,7 +108,7 @@ func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
t.Errorf("the notification lacks %q:\n%s", want, msg) t.Errorf("the notification lacks %q:\n%s", want, msg)
} }
} }
if err := checkBothSigners(h, file, raw, serverName); err != nil { if err := checkPanelSignature(h, file, raw, serverName); err != nil {
t.Errorf("the panel's signer on its own mail: %v", err) t.Errorf("the panel's signer on its own mail: %v", err)
} }
if err := verifyDKIM(raw, serverName); err != nil { if err := verifyDKIM(raw, serverName); err != nil {
+4 -4
View File
@@ -59,8 +59,8 @@ var port25Cases = []port25Case{
// drives it. // drives it.
{param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true}, {param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true},
// No antispam milter configured: the inbound journal milter // No antispam milter configured: the inbound journal milter
// alone. The submission chain (strict OpenDKIM + journal-milter) // alone. The submission chain (the strict sender check and
// must not reach inbound mail. // signer + journal-milter) must not reach inbound mail.
{param: "smtpd_milters", contains: "{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true}, {param: "smtpd_milters", contains: "{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true},
}, },
}, },
@@ -104,8 +104,8 @@ var port25Cases = []port25Case{
"INBOUND_ANTISPAM_MILTER_ACTION=accept", "INBOUND_ANTISPAM_MILTER_ACTION=accept",
}, },
check: []optionCheck{ check: []optionCheck{
// Exact equality also covers the absence of OpenDKIM/journal-milter // Exact equality also covers the absence of the submission
// sockets — anything else there would fail the comparison. // chain's sockets — anything else there would fail the comparison.
{param: "smtpd_milters", contains: "{inet:antispam:11332,default_action=accept},{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true}, {param: "smtpd_milters", contains: "{inet:antispam:11332,default_action=accept},{unix:/run/selfpost/inbound-journal.sock,default_action=accept}", wantExact: true},
}, },
}, },
+15 -11
View File
@@ -14,7 +14,7 @@ import (
// excluded: autostart=false, so it rests STOPPED until its first run and // excluded: autostart=false, so it rests STOPPED until its first run and
// EXITED after it (supervisor 4.2.5, supervisor/process.py, // EXITED after it (supervisor 4.2.5, supervisor/process.py,
// Subprocess.finish(); assumptions C-V10). // Subprocess.finish(); assumptions C-V10).
var wantRunning = []string{"syslog-relay", "opendkim", "panel", "postfix", "cert-reload", "logrotate"} var wantRunning = []string{"panel", "postfix", "cert-reload", "logrotate"}
// checkSupervisorProcesses shells into the container directly (not through the // checkSupervisorProcesses shells into the container directly (not through the
// panel's /overview page) so it works before an administrator account even // panel's /overview page) so it works before an administrator account even
@@ -130,16 +130,20 @@ func checkCitedPackageVersions(s *stack) error {
return nil return nil
} }
// checkOpenDKIMLogs waits for OpenDKIM's start line in the container's log: // checkDKIMTree: the keys and the selector table are the panel's alone,
// OpenDKIM logs through syslog(3) only, and /dev/log is the syslog-relay // directories 0700 and files 0600 (build/entrypoint.sh; internal/domain,
// program's (audit 2026-10-12, L30). OpenDKIM 2.11.0~beta2, // Keys) - no other program reads them since OpenDKIM left - and the table
// opendkim/opendkim.c: syslog(LOG_INFO, "%s v%s starting%s%s%s", // is there, written before the signer serves (cmd/panel/signer.go).
// DKIMF_PRODUCT, ...), DKIMF_PRODUCT "OpenDKIM Filter" (opendkim.h). func checkDKIMTree(s *stack) error {
func checkOpenDKIMLogs(s *stack) error { out, err := s.execIn("selfpost", "sh", "-c",
return waitFor("OpenDKIM's start line in the container log", 20*time.Second, 500*time.Millisecond, func() (bool, error) { `test -f /data/dkim/selectors && find /data/dkim ! \( -user panel -group panel \( -type d -perm 0700 -o -type f -perm 0600 \) \)`)
out := s.logs("selfpost") if err != nil {
return strings.Contains(out, "opendkim[") && strings.Contains(out, "OpenDKIM Filter v"), fmt.Errorf("no OpenDKIM line in the log") return fmt.Errorf("the DKIM tree: %v\n%s", err, out)
}) }
if strings.TrimSpace(out) != "" {
return fmt.Errorf("not the panel's alone under /data/dkim:\n%s", out)
}
return nil
} }
// checkPostfixHardening reads back what postfix-config.sh sets against // checkPostfixHardening reads back what postfix-config.sh sets against
+12 -10
View File
@@ -41,6 +41,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
wildcard := who{sc.appLogin, sc.appPassword, "alerts@" + senderDomain} wildcard := who{sc.appLogin, sc.appPassword, "alerts@" + senderDomain}
list := who{listLogin, listPassword, "billing@" + senderDomain} list := who{listLogin, listPassword, "billing@" + senderDomain}
refused := 0 // the cases the check refuses: each a row of the Outbound log
for _, c := range []struct { for _, c := range []struct {
name string name string
as who as who
@@ -66,15 +67,15 @@ func testSenderCheck(t *testing.T, sc *scenario) {
// app-list, so the wildcard application may not have it, in the // app-list, so the wildcard application may not have it, in the
// header as in the envelope (assumptions B-PS1; the envelope below). // header as in the envelope (assumptions B-PS1; the envelope below).
{"the address listed for another application", wildcard, "billing@" + senderDomain, false}, {"the address listed for another application", wildcard, "billing@" + senderDomain, false},
// Since 2.5.2 the address OpenDKIM reads decides (OpenDKIM's parser, // The address a mail program reads decides: another sender's name
// internal/milter/mailparse.go): another sender's name in the display // in a quoted display name passes, and is signed for the
// name passes, and is signed for the application's own domain // application's own domain - the key is the login's
// (passed_from_lines_signed_for_their_domain_only looks at the key). // (passed_from_lines_signed_for_their_domain_only looks at it).
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true}, {"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true},
// Past 256 bytes OpenDKIM matches its SigningTable against the // Until 2.8.0 OpenDKIM read an address past 256 bytes cut short, and
// address cut short, which here would end in the other domain's // this one would have ended in the other domain's name; refused
// name (opendkim.c, dkimf_apply_signtable(), MAXADDRESS). // then, it is the application's own address now.
{"an address longer than OpenDKIM reads", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, false}, {"a long quoted local part naming another domain", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, true},
} { } {
token := uniqueToken("from") token := uniqueToken("from")
res := attemptSend(sendAttempt{ res := attemptSend(sendAttempt{
@@ -92,6 +93,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
} }
continue continue
} }
refused++
if res.ok() { if res.ok() {
t.Errorf("%s: From: %s was accepted", c.name, c.header) t.Errorf("%s: From: %s was accepted", c.name, c.header)
continue continue
@@ -129,8 +131,8 @@ func testSenderCheck(t *testing.T, sc *scenario) {
if err != nil { if err != nil {
return false, err return false, err
} }
if n := strings.Count(rows, `">wrong From</span>`); n < 6 { if n := strings.Count(rows, `">wrong From</span>`); n < refused {
return false, fmt.Errorf("%d rows tagged wrong From, want 6", n) return false, fmt.Errorf("%d rows tagged wrong From, want %d", n, refused)
} }
return true, nil return true, nil
}); err != nil { }); err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
# SMTP sink-MX for the e2e stand (plan C.4): accepts any mail on 25 and dumps # SMTP sink-MX for the e2e stand (plan C.4): accepts any mail on 25 and dumps
# each transaction to its own file under /mail, so the test harness can read # each transaction to its own file under /mail, so the test harness can read
# the raw received message (headers + body, including the DKIM-Signature # the raw received message (headers + body, including the DKIM-Signature
# Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix # the panel's signer added) straight off disk. smtp-sink ships in the postfix
# package itself. python3-dkim (dkimpy 1.1.8-2 in trixie, /usr/bin/dkimverify) # package itself. python3-dkim (dkimpy 1.1.8-2 in trixie, /usr/bin/dkimverify)
# is the independent DKIM verifier the suite holds the panel's signer to # is the independent DKIM verifier the suite holds the panel's signer to
# (docs/plans/panel-dkim-signing.md); it looks the keys up through the # (docs/plans/panel-dkim-signing.md); it looks the keys up through the
+1 -1
View File
@@ -132,7 +132,7 @@ func (s *stack) down() {
// reclaimData chowns/chmods the /data bind mount from inside the still-running // reclaimData chowns/chmods the /data bind mount from inside the still-running
// selfpost container so the host test user can delete it afterwards. Files // selfpost container so the host test user can delete it afterwards. Files
// written as panel/postfix (setup-token 0600, opendkim tree, sqlite) otherwise // written as panel/postfix (setup-token 0600, dkim tree, sqlite) otherwise
// leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage). // leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage).
func (s *stack) reclaimData() { func (s *stack) reclaimData() {
_, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data") _, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data")
+1 -1
View File
@@ -165,7 +165,7 @@ func testSupervision(t *testing.T, sc *scenario) {
if !strings.Contains(h.logs("selfpost"), "crashexit: logrotate entered FATAL; it is not on the mail path, the container stays up") { if !strings.Contains(h.logs("selfpost"), "crashexit: logrotate entered FATAL; it is not on the mail path, the container stays up") {
t.Fatalf("crashexit did not spare logrotate:\n%s", h.logs("selfpost")) t.Fatalf("crashexit did not spare logrotate:\n%s", h.logs("selfpost"))
} }
for _, p := range []string{"opendkim", "panel", "postfix"} { for _, p := range []string{"panel", "postfix"} {
if state, status := programState(p); state != "RUNNING" { if state, status := programState(p); state != "RUNNING" {
t.Fatalf("program %s with logrotate FATAL: %s", p, status) t.Fatalf("program %s with logrotate FATAL: %s", p, status)
} }
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
# The From: parser port (internal/milter/mailparse.go) against OpenDKIM's own
# dkim_mail_parse(): builds internal/milter/testdata/mailparse_harness.c
# against the libopendkim source the image runs, in a debian:trixie
# container, and runs TestMailParseMatchesOpenDKIM with it.
#
# test/upstream/mailparse-diff.sh <opendkim source dir>
#
# The source dir is what `apt-get source opendkim` unpacks in debian:trixie
# (docs/development.md); it must contain libopendkim/.
set -eu
src=$(cd "$1" && pwd)
repo=$(cd "$(dirname "$0")/../.." && pwd)
docker run --rm -v "$src":/src:ro -v "$repo/internal/milter/testdata":/h -w /h debian:trixie sh -c '
apt-get update -qq >/dev/null && apt-get install -y -qq gcc libc6-dev >/dev/null 2>&1
gcc -O1 -static -o /h/mailparse_harness -I /src/libopendkim /h/mailparse_harness.c'
trap 'rm -f "$repo/internal/milter/testdata/mailparse_harness"' EXIT
cd "$repo" && MAILPARSE_C="$repo/internal/milter/testdata/mailparse_harness" \
go test -count=1 -v -run 'TestMailParseMatchesOpenDKIM' ./internal/milter
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
# The From: parser port against OpenDKIM's signing: starts OpenDKIM from the
# SelfPost image (default selfpost:e2e, the one make e2e builds) in a
# throwaway container with keys for three names, and runs
# TestPortAgainstOpenDKIM there against its socket.
#
# test/upstream/opendkim-probe.sh [image]
set -eu
image=${1:-selfpost:e2e}
repo=$(cd "$(dirname "$0")/../.." && pwd)
bin=$(mktemp)
trap 'rm -f "$bin"' EXIT
cd "$repo" && CGO_ENABLED=0 GOOS=linux go test -c -o "$bin" ./internal/milter
docker run --rm -v "$repo/internal/milter":/pkg:ro -v "$bin":/milter.test:ro --entrypoint sh "$image" -c '
set -e
mkdir -p /tmp/odk && cd /tmp/odk
for d in sender.e2e.test other.e2e.test mail.e2e.test; do
mkdir -p keys/$d
openssl genrsa -out keys/$d/sel.private 2048 2>/dev/null
echo "$d $d:sel:/tmp/odk/keys/$d/sel.private" >> KeyTable
echo "*@$d $d" >> SigningTable
done
printf "Mode s\nKeyTable /tmp/odk/KeyTable\nSigningTable refile:/tmp/odk/SigningTable\nSocket local:/tmp/odk/sock\nSyslog no\nRequireSafeKeys no\nBackground no\n" > o.conf
opendkim -V | head -1
opendkim -f -x /tmp/odk/o.conf &
for i in 1 2 3 4 5 6 7 8 9 10; do [ -S /tmp/odk/sock ] && break; sleep 0.5; done
cd /pkg
OPENDKIM_PROBE_SOCKET=/tmp/odk/sock OPENDKIM_PROBE_TABLE=/tmp/odk/SigningTable /milter.test -test.run TestPortAgainstOpenDKIM -test.v -test.timeout 600s'