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.
#
# 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
# 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
+26 -7
View File
@@ -8,13 +8,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added
- **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
panel's on two sockets, one for applications' mail - signed with the key of
the domain the login belongs to - and one for the panel's own, signed with
the 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
sign every message, and the e2e suite holds the two to the same `h=` and
`bh=` and has dkimpy - an independent implementation - verify each.
[the plan](docs/plans/panel-dkim-signing.md)): a milter of the panel's on
two sockets, one for applications' mail - signed with the key of the
domain the login belongs to - and one for the panel's own, signed with the
server's name. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From`
once more, as OpenDKIM signed; strict. Before OpenDKIM left, both signed
every message on the e2e stand and agreed on `h=` and `bh=`; dkimpy - an
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
+15 -18
View File
@@ -1,6 +1,6 @@
# 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:
#
# 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
# trixie is not in a source read here - the image build and the e2e sasldb
# test say whether it does.
# opendkim-tools is not installed: nothing in the image runs its programs
# (DKIM keys are made in Go, internal/domain/dkim.go).
# No DKIM package is installed: the panel signs itself (internal/milter,
# 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
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# 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 install -y --no-install-recommends \
postfix \
opendkim \
sasl2-bin \
libsasl2-modules \
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.
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services: the panel generates
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# sender map, while OpenDKIM and Postfix (different users) must read them.
# Membership in this group - plus setgid dirs under /data (set up in
# entrypoint.sh) - lets OpenDKIM read the panel-owned keys and lets Postfix
# read the sasldb2/sender map. (The supervisor control socket has a group of
# its own, `supervisor`, below.)
# Shared group bridging the unprivileged services: the panel writes the
# application SASL accounts (sasldb2) and the Postfix sender map and opens the
# milter sockets, while Postfix (another user) must read and reach them.
# Membership in this group - plus setgid dirs under /data and /run/selfpost
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
# supervisor control socket has a group of its own, `supervisor`, below.)
# The id is fixed, because the host sees it: an operator gives the TLS
# private key on the host to this group by number, so that the panel can put
# it into a full backup (guide § Taking a full backup). It lies outside the
@@ -117,7 +117,6 @@ RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid p
# fail this build rather than share the group.
RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (and the status page's
@@ -138,14 +137,13 @@ RUN groupadd --system --gid 10998 selfpost \
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
# the panel alone - not OpenDKIM's or Postfix's users, which share `selfpost`
# for the files under /data.
# the panel alone - not Postfix's user, which shares `selfpost` for the files
# under /data.
RUN groupadd --system supervisor \
&& usermod -aG supervisor panel
# Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \
RUN mkdir -p /run/selfpost /data \
&& chown panel:panel /run/selfpost /data
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
# that logrotate would silently ignore — see docs/development.md § Building
# 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=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.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.
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.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
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
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
terminate, so the whole container exits and Docker's `restart` policy starts it
again, rather than lingering "alive" with a dead Postfix/OpenDKIM/panel.
again, rather than lingering "alive" with a dead Postfix or panel.
FATAL alone did not cover the second: a program reaches FATAL only through
exits before its startsecs, and one that crashed after it was restarted for
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
@@ -37,10 +37,9 @@ import time
# Programs whose failure does not take the container down
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
# runs on demand - a reload it could not start is reported to whoever asked for
# it - syslog-relay carries OpenDKIM's log lines, not mail, and cert-reload
# looks for a renewed certificate once a day (a crash of it leaves the one
# Postfix serves).
SPARED = {"logrotate", "postfix-reload", "syslog-relay", "cert-reload"}
# it - and cert-reload looks for a renewed certificate once a day (a crash of
# it leaves the one Postfix serves).
SPARED = {"logrotate", "postfix-reload", "cert-reload"}
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
# program that does not stay up. A program on the mail path restarts in
+2 -2
View File
@@ -75,7 +75,7 @@ func TestCrashexitCountsCrashes(t *testing.T) {
for i := 0; i < 6; i++ {
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
}
calm = append(calm, exit("opendkim", "0"), exit("opendkim", "0"), exit("opendkim", "0"))
calm = append(calm, exit("postfix", "0"), exit("postfix", "0"), exit("postfix", "0"))
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
}
@@ -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") {
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)
}
}
+44 -48
View File
@@ -58,10 +58,11 @@ esac
# — the one place still running as root — before handing off to supervisord,
# which starts the panel as the panel user.
#
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
# chown would leave that mode in place — opendkim then cannot read KeyTable and
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
# Mode must stay world-traversable (0755): Postfix reaches its trees under
# /data as another user. Go's testing.TempDir is 0700, and a bare chown would
# leave that mode in place — OpenDKIM, which read its tables there until
# 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
# mode 0o700".)
chown panel:panel /data
@@ -85,8 +86,8 @@ drop_links() {
fi
done
}
drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/opendkim/keys /data/opendkim/KeyTable /data/opendkim/SigningTable \
drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/dkim/keys /data/dkim/selectors /data/opendkim/keys \
/data/sasl/sasldb2 /data/postfix/queue \
/data/postfix/sender_login_maps /data/postfix/relay_domains \
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
@@ -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
# later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below, 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
# 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
@@ -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 \
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
# DKIM key tree. The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup:
# - group `selfpost` + setgid on directories so anything the panel creates
# inherits the shared group OpenDKIM traverses (inode(7), "The
# set-group-ID bit": files created in such a directory take its group);
# - private keys and tables group-readable (0640);
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
# daemon comes up cleanly with no domains yet.
mkdir -p /data/opendkim/keys
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
[ -e "$t" ] || : > "$t"
done
chown -R panel:selfpost /data/opendkim
find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
# the keys, writes the table and signs (internal/domain, Keys; internal/milter,
# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with
# OpenDKIM through group selfpost. The first start without /data/dkim copies
# them over and leaves the old tree as it was, for an image of 2.7.x rolled
# back to (docs/plans/panel-dkim-signing.md, Q2). The copy is made as the
# panel user, which can read nothing there the panel could not, into a
# directory beside, renamed into place when it is whole: a start that stops
# midway copies again. A copy that fails stops the start (set -e) - the
# panel would otherwise make the server's own name a new key, and its
# published record would no longer match. The selector table is written by
# the panel before its signer serves (cmd/panel/signer.go).
# (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
# -P: never follow a symbolic link in the source.)
rm -rf /data/dkim.new
if [ ! -e /data/dkim ] && [ -d /data/opendkim/keys ]; then
setpriv --reuid=panel --regid=panel --clear-groups -- \
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
# 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
find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
# the milter sockets — OpenDKIM's and the panel's — not just probe them at
# start-up. The sockets are created by the opendkim and panel
# users respectively, so bridge them to `postfix` through the shared `selfpost`
# group: group-owned + setgid dirs mean each socket created inside inherits
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
# (default_action=tempfail), rejects all mail.
mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim
# The milter socket directory. Postfix (user `postfix`) must actually CONNECT
# to the panel's milter sockets, not just probe them at start-up. The panel
# user creates them, so they are bridged to `postfix` through the shared
# `selfpost` group: a group-owned + setgid dir means each socket created
# inside inherits group `selfpost`, and group-traversable (2750) lets postfix
# reach it. Without this, smtpd cannot talk to the signer and, because
# signing is strict (default_action=tempfail), defers all mail.
mkdir -p /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
# 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
@@ -210,18 +217,7 @@ chmod 2750 /run/opendkim /run/selfpost
# downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
# The container's /dev/log: a link to the socket the syslog-relay program (the
# panel's binary, as `panel`) listens on, so what OpenDKIM logs through
# syslog(3) reaches the container's log (build/supervisord.conf; audit
# 2026-10-12, L30). The directory is the relay's and reachable by all: the
# socket is the container's local log, as /dev/log is on a host.
mkdir -p /run/syslog
chown panel:panel /run/syslog
chmod 0755 /run/syslog
rm -f /run/syslog/log
ln -sfn /run/syslog/log /dev/log
rm -f /run/selfpost/*.sock /run/selfpost/*.alive
# 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
-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_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check
# (strict) and its journal-milter (monitoring, fail-open). Fixed container
# paths, matched by postfix-wrapper.sh.
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
# Milter sockets, all the panel's: its journal-milter (monitoring,
# fail-open), the sender check and the DKIM signer (both strict). Fixed
# container paths, matched by postfix-wrapper.sh.
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# The panel's sender check (the From: line against the login, strict).
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
@@ -400,26 +399,23 @@ postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
# if it is unreachable, defer rather than send unsigned). The journal-milter is
# monitoring only and is fail-open (default_action=accept): its failure must
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
# Milter chain. The sender check comes first and is strict
# (default_action=tempfail): it holds the From: line against the login, the
# address a receiver holds the signature to, so a message it could not judge
# 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
# against the login, which is what OpenDKIM then chooses the key by, so a
# message it could not judge must not reach the signer. While the panel does
# not answer on that socket, mail is deferred (docs/security.md).
#
# 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.
# The panel's DKIM signer follows, strict too: unreachable, it defers rather
# than send unsigned (docs/plans/panel-dkim-signing.md). It signs with the
# key of the login's domain. The journal-milter is monitoring only and is
# fail-open (default_action=accept): its failure must never block the relay.
# Per-milter settings use Postfix 3.0+ brace syntax.
#
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
# 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
# server's own name; the journal-milter is not on this chain, since they are
# not an application's mail. Bounces Postfix
# signer's own socket for it signs them with the key of the server's own
# name; the journal-milter is not on this chain, since they are not an
# application's mail. Bounces Postfix
# generates itself do not pass it (internal_mail_filter_classes is empty).
#
# Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
@@ -431,8 +427,8 @@ postconf -e \
postconf -e \
"milter_protocol=6" \
"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 }" \
"non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }"
"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 }"
# 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
@@ -440,8 +436,8 @@ postconf -e \
# 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,
# 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 it as well.
# to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures, before the
# panel's, were exposed to it as well.
postconf -e "disable_mime_output_conversion=yes"
# 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)
# must fail open just like a crash, not stall mail acceptance until the Postfix
# 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
# 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,
@@ -518,7 +514,7 @@ fi
# debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
# uncommented; conf/master.cf:12). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# smtp/unix). When either is on: no SASL, no signing; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_DOMAINS_SETTING=""
@@ -622,8 +618,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
"smtp_tls_policy_maps="
fi
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is
# for the SUBMISSION path only: OpenDKIM must not gate someone else's
# Milters on port 25. main.cf's chain (sender check and signer strict +
# 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
# would file inbound mail into the outbound send log under the *sender's*
# 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
# reach the milter sockets before they are listening.
#
# It blocks until the milter sockets — OpenDKIM, the panel's sender check,
# DKIM signer (two) and journal-milter and, with the inbound relay on, its
# It blocks until the milter sockets — the panel's sender check, DKIM signer
# (two) and journal-milter and, with the inbound relay on, its
# inbound journal milter —
# are present, then runs `postfix start-fg` and stays its parent. If they are
# not ready within the timeout it exits non-zero WITHOUT starting Postfix.
@@ -17,10 +17,9 @@
#
# This handles cold start only. Once Postfix runs, a milter that does not
# answer costs what its default_action says (build/postfix-config.sh): mail is
# deferred for OpenDKIM and the sender check, and passes the journal-milter.
# deferred for the signer and the sender check, and passes the journal-milter.
set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
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}"
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
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
fi
+10 -44
View File
@@ -1,8 +1,8 @@
; SelfPost process supervision.
;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
; the sender check's and the journal-milter's sockets), then the Postfix
; wrapper — which additionally blocks until the milter sockets are ready
; Start ORDER is enforced by priority=: the panel (which opens the sender
; check's, the DKIM signer's and the journal-milter's sockets), then the
; Postfix wrapper — which additionally blocks until the milter sockets are ready
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one
@@ -17,7 +17,7 @@
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
; transition()): an exit after it is EXITED and simply restarted, for ever -
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
; postfix-reload's and syslog-relay's failures are left on Server > Health
; postfix-reload's and cert-reload's failures are left on Server > Health
; (build/crashexit.py).
[supervisord]
@@ -29,12 +29,11 @@ pidfile=/run/supervisord.pid
loglevel=info
; The control socket is reachable by the `supervisor` group, whose one member
; is the panel (the unprivileged `panel` user): it asks supervisord to signal
; OpenDKIM after it rewrites the DKIM tables, to run the Postfix reload, and
; for the processes' state. Whoever reaches the socket can stop, start or
; restart any program, so it is not the shared `selfpost` group's: OpenDKIM
; and Postfix are in that one for the files under /data, and a fault in either
; must not reach every other program (audit 2026-10-12, stage 5). Not
; is the panel (the unprivileged `panel` user): it asks supervisord to run the
; Postfix reload, and for the processes' state. Whoever reaches the socket can
; stop, start or restart any program, so it is not the shared `selfpost`
; group's: Postfix is in that one for the files under /data, and a fault in
; it must not reach every other program (audit 2026-10-12, stage 5). Not
; reachable by world. No HTTP auth is configured because access is gated by
; socket permissions, not the network. (supervisor 4.2.5,
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
@@ -50,38 +49,6 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/supervisor.sock
; The container's /dev/log (a link to the socket below, build/entrypoint.sh):
; what OpenDKIM - which logs through syslog(3) and nothing else - sends there
; comes out on stdout, into the container's log (internal/syslogrelay; audit
; 2026-10-12, L30). Before OpenDKIM, so its first lines are not lost; a
; message sent while nothing listens is dropped by syslog(3), not queued.
[program:syslog-relay]
command=/usr/local/bin/panel -syslog-relay=/run/syslog/log
user=panel
priority=50
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:opendkim]
; 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
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; 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
; reload` itself, and it cannot signal the Postfix master directly: `postfix
; 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 as the supervised process itself). Instead the panel asks
; foreground process never reaches it. Instead the panel asks
; supervisord — over the group-accessible control socket — to run this program,
; 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
+7 -9
View File
@@ -27,16 +27,14 @@ type mailStack struct {
Lists *inbound.Lists
DMARC *dmarc.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
dmarcEnabled bool
}
func newMailStack(cfg config, st *store.Store) *mailStack {
pf := postfix.New(cfg.postfixDir)
odk := domain.NewOpenDKIM(cfg.opendkimDir)
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,
// and signed as it.
domains.SetServerName(cfg.hostname)
@@ -45,15 +43,15 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
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
// reloads both daemons — the same work as the Status page's Reload button.
// Resync rebuilds the selector table and Postfix's maps from SQLite and
// reloads Postfix — the same work as the Health page's Reload button.
func (m *mailStack) Resync() error {
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 {
return fmt.Errorf("postfix resync: %w", err)
@@ -78,7 +76,6 @@ func (m *mailStack) Resync() error {
func (m *mailStack) skipReloadForTest() {
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
@@ -116,7 +113,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile,
TLSReloadInterval: cfg.tlsReloadInterval,
OpenDKIMSocket: cfg.opendkimSocket,
SignSocket: cfg.signSocket,
SignLocalSocket: cfg.signLocalSocket,
JournalSocket: cfg.journalSocket,
SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays,
+9 -22
View File
@@ -28,7 +28,6 @@ import (
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/syslogrelay"
)
func main() {
@@ -48,21 +47,12 @@ func main() {
}
showVersion := flag.Bool("version", false, "print version and exit")
relay := flag.String("syslog-relay", "", "relay syslog(3) datagrams sent to this socket path to stdout, and do nothing else")
flag.Parse()
if *showVersion {
fmt.Println(buildinfo.Version)
return
}
if *relay != "" {
// The container's /dev/log, for OpenDKIM (internal/syslogrelay;
// build/supervisord.conf, [program:syslog-relay]).
if err := syslogrelay.Run(*relay, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
log.SetFlags(log.LstdFlags | log.LUTC)
log.SetPrefix("panel: ")
@@ -98,19 +88,17 @@ type config struct {
rateLimitMessagesPerIP int
rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror
// build/postfix-config.sh, so the status page checks exactly what Postfix
// was configured with.
tlsCertFile string
opendkimSocket string
// Read-only input to the panel's status page: the certificate Postfix
// serves. The default mirrors build/postfix-config.sh, so the status
// page checks exactly what Postfix was configured with.
tlsCertFile string
// tlsServedCertFile is the certificate Postfix serves (its copy of
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
// how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string
tlsReloadInterval time.Duration
opendkimDir string
dkimDir string
dkimSelectorDef string
saslDBPath string
@@ -184,11 +172,10 @@ func loadConfig() config {
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
// Per-domain DKIM state (architecture.md § DKIM signing): the keys and
// the selector table, the layout entrypoint.sh prepares.
dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
// 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) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(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 {
return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
-15
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"log"
"time"
"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)
dns := dnscheck.New(cfg.dnsResolvers)
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
// first start of a version that signs the server's own name they do not
// have its line yet. A failure here costs the signature, not the message:
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, mlfi_eoh(): with no
// SigningTable match and Mode s (build/opendkim.conf) the message is
// accepted unsigned. A line whose key the KeyTable lacks or that does not
// load is another matter - dkimf_apply_signtable() < 0 there answers
// SMFIS_TEMPFAIL (assumptions A-47).
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
} else if rebuilt {
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
}
n := notify.New(st, notify.Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) {
rec, err := ms.Domains.DKIMRecord(d)
+2 -2
View File
@@ -127,7 +127,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
extract(t, archiveOf(t, seedPanelProject(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)
if err != nil {
t.Fatalf("open key: %v", err)
@@ -139,7 +139,7 @@ func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
t.Error("a refused restore ran its Resync")
return nil
})
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private"
want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
}
+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
// re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} {
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil {
@@ -268,10 +268,10 @@ func seedPanelProject(t *testing.T) string {
}
for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} {
full := filepath.Join(dataDir, path)
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
// what, and what OpenDKIM signs.
// The table the mail path itself goes by: whom Postfix lets send as what.
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
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/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// 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,
// 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
// panel writes for OpenDKIM too while both sign (docs/plans/panel-dkim-signing.md,
// stage 1).
func serveSigner(ctx context.Context, cfg config) error {
keys := milter.NewSignKeys(filepath.Join(cfg.opendkimDir, "KeyTable"), filepath.Join(cfg.opendkimDir, "keys"))
// The keys and selectors come from the key tree and the selector table under
// dkimDir (internal/domain, Keys). Before it serves, the table is made to
// hold the server's own name - and written at all on the first start of
// 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
// (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())
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
# self-healing. What IS applied: no privilege escalation past what 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;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data
@@ -113,12 +113,18 @@ services:
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
# `panel` user; Postfix switches to its own users;
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
# different uid) on domain add/remove to reload its
# KeyTable/SigningTable; without it os.kill() fails
# with EPERM even though the caller is root, because
# the kernel's signal permission check for
# cross-uid kill() still consults CAP_KILL.
# - KILL — root signals processes of other uids: supervisord
# stops and restarts the panel (supervisor 4.2.5,
# supervisor/process.py:467, options.kill()), and
# Postfix's master ends its children, which run as
# `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
# 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
# self-healing; without them chmod fails with EPERM and the container
# 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`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
`PANEL_COOKIE_SECURE` (`true`), `DKIM_DIR` (`/data/dkim` — the keys and
the selector table), `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/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 |
|---|---|
| `postfix` | `3.10.13-0+deb13u1` |
| `opendkim` | `2.11.0~beta2-9.1+b1` |
| `libopendkim11` | `2.11.0~beta2-9.1+b1` |
| `libmilter1.0.1` | `8.18.1-6+b1` |
| `libsasl2-2` | `2.1.28+dfsg1-9` |
| `sasl2-bin` | `2.1.28+dfsg1-9` |
| `supervisor` | `4.2.5-3` |
+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
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.
- [ ] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
- [x] Chain: OpenDKIM out of both milter lists.
- [x] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
`Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor`
(its only caller is the reload), `internal/syslogrelay`,
`internal/milter/mailparse.go` and its tests and testdata, the
sender-check texts; Resync's "OpenDKIM's tables" step; Health's
liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`.
- [ ] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
- [x] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost
opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL`
capability (its only use was the reload).
- [ ] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint
capability (its only use was the reload) - **kept, see below**.
- [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
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.
*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
- [ ] 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) |
| port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — |
| base-trixie | The image's base from Debian bookworm (LTS since 2026-07-12) to trixie: Postfix 3.10 with the 2026 security batches | shipped `2.7.0` | — | — |
| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; 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) | — | — |
| 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) |
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// the old file back (fsync(2): syncing a file "does not necessarily ensure
// that the entry in the directory containing the file has also reached
// disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
// OpenDKIM goes through here.
// the DKIM signer goes through here.
func Write(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
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
// schema/format skew between versions cannot silently corrupt state
// (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
// 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
+3 -3
View File
@@ -43,7 +43,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
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, "postfix", "sender_login_maps"), "@example.com login")
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{
DataArchivePrefix + ManifestName,
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
// 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{
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
ComposeFileName, EnvFileName,
+1 -1
View File
@@ -5,7 +5,7 @@
//
// 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,
// 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.
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
// 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 {
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
// 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).
// The key stays while the tables may still name it.
// purged logins when the signing tables failed first (audit 2026-10-12, L17).
// The key stays while the selector table may still name it.
func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
apps := &countingApps{}
s, odk := newTestService(t, apps)
s, keys := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
t.Fatal(err)
}
keyDir := filepath.Join(odk.keysDir, d.Name)
keyDir := filepath.Join(keys.keysDir, d.Name)
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)
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) {
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
// 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")
if err != nil {
t.Fatal(err)
@@ -64,7 +72,7 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
if _, err := os.Stat(keyDir); !os.IsNotExist(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)
}
}
@@ -73,16 +81,16 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
// rebuilt, so the key goes.
func TestDeleteWithTheSenderMapFailing(t *testing.T) {
apps := &countingApps{resyncErr: errors.New("postfix away")}
s, odk := newTestService(t, apps)
s, keys := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
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) {
t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", 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
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so
// it survives container restarts (architecture.md § OpenDKIM).
// generation, the selector table the panel's signer reads, and the
// orchestration that keeps the SQLite registry and the on-disk keys in
// agreement (architecture.md § DKIM signing). Key material lives under /data
// so it survives container restarts.
package domain
import (
@@ -32,23 +32,15 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
}
// 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
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf
// and entrypoint.sh); the parent directory carries setgid so the group is
// 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).
// atomically and readable by the panel user alone (0600): the panel's signer
// is the only reader (internal/milter, readKey()). The write is atomic (temp
// file + rename) so the signer never reads a half-written key.
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE 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
@@ -79,7 +71,7 @@ type DKIMRecord struct {
}
// 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.
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
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
// 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).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
return atomicfile.Write(path, data, perm)
+2 -2
View File
@@ -21,8 +21,8 @@ func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o640 {
t.Errorf("key perm = %o, want 0640", perm)
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("key perm = %o, want 0600", perm)
}
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 (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRenderTables(t *testing.T) {
keysDir := "/data/opendkim/keys"
func TestRenderSelectors(t *testing.T) {
// Deliberately out of order to exercise the deterministic sort.
domains := []SigningDomain{
{Name: "zeta.example", Selector: "selfpost"},
{Name: "alpha.example", Selector: "sel2"},
}
kt, st, err := renderTables(keysDir, domains)
got, err := renderSelectors(domains)
if err != nil {
t.Fatalf("renderTables: %v", err)
t.Fatalf("renderSelectors: %v", err)
}
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" +
"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 want := "alpha.example sel2\nzeta.example selfpost\n"; string(got) != want {
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
}
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n"
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)
if empty, err := renderSelectors(nil); err != nil || len(empty) != 0 {
t.Errorf("renderSelectors(nil) = %q, %v; want an empty table", empty, err)
}
}
@@ -49,6 +34,9 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
{"../etc", "selfpost"},
{"", "selfpost"},
{"example.com", ""},
{"..", "selfpost"},
{".", "selfpost"},
{"example.com", ".."},
}
for _, b := range bad {
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.
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
// newTestKeys builds a manager rooted at a temp dir.
func newTestKeys(t *testing.T) *Keys {
t.Helper()
dir := 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
return NewKeys(t.TempDir())
}
func TestEnsureKeyReusesExisting(t *testing.T) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
created, err := o.EnsureKey("example.com", "selfpost")
if err != nil {
@@ -101,28 +82,41 @@ func TestEnsureKeyReusesExisting(t *testing.T) {
}
}
func TestRebuildWritesTablesAndReloads(t *testing.T) {
o, reloads := newTestOpenDKIM(t)
// The key and the table are the panel's alone: no other program reads them.
func TestRebuildWritesThePanelsOwnFiles(t *testing.T) {
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
t.Fatalf("Rebuild: %v", err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
if signed, err := o.signs("example.com", "selfpost"); err != nil || !signed {
t.Errorf("signs after Rebuild = %v, %v", signed, err)
}
kt, err := os.ReadFile(o.keyTablePath)
if err != nil {
t.Fatal(err)
if signed, err := o.signs("example.com", "other"); err != nil || signed {
t.Errorf("signs with another selector = %v, %v", signed, err)
}
if !strings.Contains(string(kt), "example.com:selfpost:") {
t.Errorf("KeyTable missing entry: %q", kt)
if runtime.GOOS == "windows" {
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) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
@@ -139,7 +133,7 @@ func TestRemoveKey(t *testing.T) {
}
func TestRecordFromWrittenKey(t *testing.T) {
o, _ := newTestOpenDKIM(t)
o := newTestKeys(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
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"
)
func readTables(t *testing.T, odk *OpenDKIM) (keyTable, signingTable string) {
func readSelectors(t *testing.T, keys *Keys) string {
t.Helper()
kt, err := os.ReadFile(odk.keyTablePath)
data, err := os.ReadFile(keys.selectorsPath)
if err != nil {
t.Fatalf("read KeyTable: %v", err)
t.Fatalf("read the selector table: %v", err)
}
st, err := os.ReadFile(odk.signingTablePath)
if err != nil {
t.Fatalf("read SigningTable: %v", err)
}
return string(kt), string(st)
return string(data)
}
// 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
// can send as it.
// the selector table - without being one: no row in the registry, so no
// application can send as it.
func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org")
if _, err := svc.Add("example.com"); err != nil {
t.Fatalf("add domain: %v", err)
}
kt, st := readTables(t, odk)
for _, want := range []string{"mail.example.org mail.example.org:selfpost:", "example.com example.com:selfpost:"} {
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)
if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
}
domains, err := svc.List()
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,
// the domain's; deleting the domain leaves the server's mail signed with it.
func TestServerNameThatIsAlsoADomain(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("example.com")
d, err := svc.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
_, st := readTables(t, odk)
if strings.Count(st, "*@example.com ") != 1 {
t.Errorf("the name is in the SigningTable more than once:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("selector table = %q, want the name once", got)
}
asDomain, err := svc.DKIMRecord(d)
if err != nil {
@@ -84,9 +73,8 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
if err := svc.Delete(d.ID); err != nil {
t.Fatalf("delete domain: %v", err)
}
_, st = readTables(t, odk)
if !strings.Contains(st, "*@example.com example.com\n") {
t.Errorf("deleting the domain stopped the server's own mail being signed:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("deleting the domain stopped the server's own mail being signed: %q", got)
}
after, err := svc.ServerMail()
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
// tables without its line until something rebuilds them. The start does, once.
// The first start of 2.8.0 finds no selector table - OpenDKIM's tables were
// the earlier versions' - and an installation may lack the server's line.
// The start rebuilds, once.
func TestEnsureServerSignedRebuildsOnce(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err)
}
reloads := 0
odk.reload = func() error { reloads++; return nil }
if err := os.Remove(keys.selectorsPath); err != nil {
t.Fatal(err)
}
// The tables as the earlier version left them; now the name is known.
svc.SetServerName("mail.example.org")
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
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 ") {
t.Errorf("SigningTable after the rebuild:\n%s", st)
if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
t.Errorf("selector table after the rebuild = %q, want %q", got, want)
}
if rebuilt, err := svc.EnsureServerSigned(); err != nil || rebuilt {
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.
other, otherOdk := newTestService(t, &fakeApps{})
other, otherKeys := newTestService(t, &fakeApps{})
other.SetServerName("mail.example.org.evil.example")
if _, err := other.EnsureServerSigned(); err != nil {
t.Fatal(err)
}
other.SetServerName("mail.example.org")
if signed, err := otherOdk.signs("mail.example.org", "selfpost"); err != nil || signed {
if signed, err := otherKeys.signs("mail.example.org", "selfpost"); err != nil || signed {
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
// default selector was changed - are rebuilt: OpenDKIM must sign with the key
// A table that signs the server's name with the key of another selector - the
// default selector was changed - is rebuilt: the signer must sign with the key
// whose record the panel shows.
func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
svc.SetServerName("mail.example.org")
if _, err := svc.EnsureServerSigned(); err != nil {
t.Fatal(err)
@@ -145,24 +130,23 @@ func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
t.Fatalf("after the selector changed: rebuilt = %v, %v; want a rebuild", rebuilt, err)
}
kt, _ := readTables(t, odk)
if !strings.Contains(kt, "mail.example.org mail.example.org:sp2027:") || strings.Contains(kt, ":selfpost:") {
t.Errorf("KeyTable after the rebuild:\n%s", kt)
if got := readSelectors(t, keys); got != "mail.example.org sp2027\n" {
t.Errorf("selector table after the rebuild = %q", got)
}
mail, err := svc.ServerMail()
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.
func TestNoServerName(t *testing.T) {
svc, odk := newTestService(t, &fakeApps{})
svc, keys := newTestService(t, &fakeApps{})
if _, err := svc.Add("example.com"); err != nil {
t.Fatal(err)
}
if _, st := readTables(t, odk); strings.Count(st, "\n") != 1 {
t.Errorf("SigningTable has more than the one domain:\n%s", st)
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
t.Errorf("selector table has more than the one domain: %q", got)
}
if _, err := svc.ServerMail(); err == nil {
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,
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
// database and Postfix sender map its applications touch. Callers (the web
// handlers) validate user input first; Service keeps the stores in agreement
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
// product.md).
// the on-disk DKIM keys and the selector table the signer reads, plus — on
// deletion — the SASL database and Postfix sender map its applications touch.
// Callers (the web handlers) validate user input first; Service keeps the
// stores in agreement and drives the Postfix reloads (architecture.md § DKIM
// signing, product.md).
type Service struct {
store *store.Store
odk *OpenDKIM
keys *Keys
apps Applications
selector string
// 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
// 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
// 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
// has validated; it is not user input.
func (s *Service) SetServerName(hostname string) {
@@ -74,23 +74,23 @@ func (s *Service) ServerMail() (ServerMail, error) {
if err != nil {
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
}
rec, err := s.odk.Record(s.serverName, selector)
rec, err := s.keys.Record(s.serverName, selector)
if err != nil {
return ServerMail{}, err
}
return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil
}
// EnsureServerSigned makes sure OpenDKIM's tables have the line for the
// server's own name, rebuilding them if they do not. The tables are otherwise
// rebuilt only when a domain is added or deleted, so an installation that was
// upgraded to a version with notifications would go on sending them unsigned
// until then, and one whose DKIM_SELECTOR_DEFAULT was changed would go on
// signing with the key of the old selector while the panel shows the record
// of the new. It reports whether it had to rebuild.
// EnsureServerSigned makes sure the selector table has the line for the
// server's own name, rebuilding it if it does not - or if there is no table
// yet, as on the first start of 2.8.0, whose table replaced OpenDKIM's. The
// table is otherwise rebuilt only when a domain is added or deleted, so an
// installation whose DKIM_SELECTOR_DEFAULT was changed would go on signing
// with the key of the old selector while the panel shows the record of the
// new. It reports whether it had to rebuild.
func (s *Service) EnsureServerSigned() (bool, error) {
if s.serverName == "" {
return false, nil
@@ -99,7 +99,7 @@ func (s *Service) EnsureServerSigned() (bool, error) {
if err != nil {
return false, err
}
signed, err := s.odk.signs(s.serverName, selector)
signed, err := s.keys.signs(s.serverName, selector)
if err != nil {
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
// used only on deletion, to clear the SASL accounts and sender-map bindings of
// the domain's applications.
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
func NewService(st *store.Store, keys *Keys, apps Applications, selectorDefault string) *Service {
return &Service{store: st, keys: keys, apps: apps, selector: selectorDefault}
}
// 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
// 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
// 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
// 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
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
// domain that OpenDKIM cannot sign.
// table's rebuild fails, the row is rolled back so we never leave a registered
// domain the signer cannot sign for.
func (s *Service) Add(name string) (store.Domain, error) {
d, err := s.store.AddDomain(name, s.selector)
if err != nil {
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)
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
// order matters: the applications' SASL accounts are cleared first, while their
// 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
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
// and Postfix stops authorising the domain's senders — before the DKIM key is
// deleted.
// their addresses) go via the DB cascade; then the selector table and the
// Postfix sender map are rebuilt from what remains — so the signer stops
// signing and Postfix stops authorising the domain's senders — before the
// DKIM key is deleted.
func (s *Service) Delete(id int64) error {
d, err := s.store.GetDomain(id)
if err != nil {
@@ -204,26 +204,26 @@ func (s *Service) Delete(id int64) error {
return err
}
// From here the domain is deleted, whatever fails after: each rebuild
// runs - the sender map was left with the purged logins when OpenDKIM's
// failed first - and a failure says the domain is gone (audit
// 2026-10-12, L17).
// runs - the sender map was left with the purged logins when the
// signing tables failed first - and a failure says the domain is gone
// (audit 2026-10-12, L17).
var errs []error
tablesRebuilt := true
if err := s.resync(); err != nil {
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 {
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
// 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 -
// 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.
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))
}
}
@@ -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).
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
@@ -263,13 +263,12 @@ func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
}
// Resync regenerates the OpenDKIM tables from the registry and reloads
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
// surface) and doubles as a recovery path if the tables ever drift from the
// database.
// Resync regenerates the selector table from the registry. It backs the
// manual reload button (architecture.md § Panel HTTP surface) and doubles as
// a recovery path if the table ever drifts from the database.
//
// 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
// points to after such a deletion finishes it here (review of audit stage 4).
func (s *Service) Resync() error {
@@ -284,13 +283,13 @@ func (s *Service) Resync() error {
for _, d := range domains {
keep[d.Name] = true
}
names, err := s.odk.KeyDomains()
names, err := s.keys.KeyDomains()
if err != nil {
return err
}
for _, name := range names {
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)
}
}
@@ -298,7 +297,7 @@ func (s *Service) Resync() error {
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 {
domains, err := s.store.ListDomains()
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
// sending domain already and so in the tables above.
// sending domain already and so in the table above.
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)
}
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 {
return DomainExport{}, err
}
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
pem, err := s.keys.ExportKey(d.Name, d.DKIMSelector)
if err != nil {
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
// (architecture.md § Persistence): it stores the imported DKIM key (so 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.
//
// 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
}
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)
return store.Domain{}, err
}
+5 -6
View File
@@ -43,18 +43,17 @@ func (f *fakeApps) ImportApplication(domainID int64, login, mode string, address
return nil
}
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
// a temp dir, with the OpenDKIM reload signal stubbed out.
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
// newTestService builds a Service over a fresh SQLite store and key tree in a
// temp dir.
func newTestService(t *testing.T, apps Applications) (*Service, *Keys) {
t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
odk := NewOpenDKIM(t.TempDir())
odk.reload = func() error { return nil }
return NewService(st, odk, apps, "selfpost"), odk
keys := NewKeys(t.TempDir())
return NewService(st, keys, apps, "selfpost"), keys
}
func TestExportImportRoundTrip(t *testing.T) {
+6 -8
View File
@@ -116,14 +116,14 @@ func TestCheckCertificate(t *testing.T) {
func TestCheckSocket(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "opendkim.sock")
sock := filepath.Join(dir, "sign.sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Skipf("unix sockets unavailable here: %v", err)
}
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)
}
@@ -131,7 +131,7 @@ func TestCheckSocket(t *testing.T) {
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
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)
}
@@ -139,7 +139,7 @@ func TestCheckSocket(t *testing.T) {
if err := os.WriteFile(plain, nil, 0o600); err != nil {
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)
}
}
@@ -170,8 +170,7 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
func TestLivenessFromParsedProcesses(t *testing.T) {
// Composed, in the columns of TestParseProcesses.
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
allRunning := `panel RUNNING pid 22, uptime 0:04:09
postfix RUNNING pid 23, uptime 0:04:08
postfix-reload STOPPED Not started
`
@@ -182,8 +181,7 @@ postfix-reload STOPPED Not started
}
}
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfixDead := `panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly
`
procs = parseProcesses(postfixDead)
+4 -5
View File
@@ -8,14 +8,13 @@ import (
// mailPathPrograms are the supervised processes whose absence means the
// container should not report healthy to orchestrators.
var mailPathPrograms = map[string]bool{
"opendkim": true,
"panel": true,
"postfix": true,
"panel": true,
"postfix": true,
}
// Liveness reports whether the mail path is healthy enough for container
// probes. It requires opendkim, panel, and postfix to be RUNNING under
// supervisord.
// probes. It requires panel - whose roles include the DKIM signer - and
// postfix to be RUNNING under supervisord.
func Liveness() error {
procs, err := Processes()
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).
func TestStaleSocketFileIsNotListening(t *testing.T) {
if runtime.GOOS == "windows" {
@@ -163,8 +163,8 @@ func TestStaleSocketFileIsNotListening(t *testing.T) {
t.Fatalf("the socket file should outlive its listener: %v", err)
}
got := CheckSocket("OpenDKIM", sock, true)
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the OpenDKIM socket") {
got := CheckSocket("dkim-signer", sock, true)
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the dkim-signer socket") {
t.Errorf("stale required socket: %+v", got)
}
if got := CheckSocket("send-log", sock, false); got.Status != StatusWarn {
+7 -10
View File
@@ -21,9 +21,10 @@ type Socket struct {
Detail string
}
// CheckSocket checks a milter socket. required distinguishes the two milters:
// Postfix attaches OpenDKIM with default_action=tempfail, so a missing socket
// stops mail leaving the server, while the journal-milter is attached with
// CheckSocket checks a milter socket. required distinguishes the milters:
// Postfix attaches the DKIM signer and the sender check 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
// log stops being written (build/postfix-config.sh smtpd_milters; Postfix
// 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.
// 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
// a killed OpenDKIM or panel leaves a file that refuses every connection -
// assumptions A-38. The connection is closed before a byte is sent, which
// none of the three milters logs: OpenDKIM's libmilter reads the EOF as
// 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
// a killed panel leaves a file that refuses every connection - assumptions
// A-38. The connection is closed before a byte is sent, which none of the
// panel's milters logs: go-milter v0.4.1 returns quietly on io.EOF
// (session.go, HandleMilterCommands()).
func CheckSocket(name, path string, required bool) Socket {
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
// 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
// take as they come. One shape Postfix does not write here: opendkim lines in
// mail.log (OpenDKIM logs through syslog(3), to the container log - build/opendkim.conf).
// take as they come.
// 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
@@ -823,7 +822,7 @@ func TestQueueLinesPicksOutOneMessage(t *testing.T) {
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
// Same run of characters, longer id: not this message.
"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)",
)
+2 -2
View File
@@ -26,8 +26,8 @@ import (
// it again.
//
// 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
// answer and when the journal milter refuses under a level-2 limit.
// The reject lines do not: the same words are written when the DKIM signer
// 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
// 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
// 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
// that use the same words - a level-2 refusal of the journal milter, OpenDKIM
// not answering. Those - smtpd 1149, 1190 and 1201, the second client of
// 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed,
// that use the same words - a level-2 refusal of the journal milter, the DKIM
// signer not answering. Those - smtpd 1149, 1190 and 1201, the second client
// 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).
func TestDeferredCounter(t *testing.T) {
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]: 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]: 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`,
// 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]: 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.
`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`,
// The signer hung in the middle of a message: another socket.
`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
// 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).
//
// 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,
// recipient), giving the panel a structured, filterable history that raw
// 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
// that sends it, under the rule Postfix holds the envelope sender to
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by
// that line, so without the check an application could have mail signed as
// another domain of the server, or as the server itself.
//
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the
// address judged is the one OpenDKIM signs for.
// (docs/plans/from-header-binding.md). The signer signs with the key of the
// domain the login belongs to (sign.go), and a receiver holds that signature
// 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
// of the server, or as the server itself.
//
// Unlike the journal milter this one is attached strictly
// (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():
// the SASL login is compared with an owner by strcasecmp_utf8();
// 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,
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
// domain is that of the first From: field, as dkim_mail_parse() reads it.
// "451 4.7.1" for a milter that does not answer.
// 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
@@ -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
// 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
// Kelvin sign for "k" - for an address OpenDKIM, folding ASCII, does not
// sign (review of stage 2c).
// Kelvin sign for "k" - for an address that is not the domain's (review of
// stage 2c).
func (b *Bindings) Allowed(login, addr string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -223,8 +220,10 @@ func (b *Bindings) Allowed(login, addr string) (bool, error) {
if bare := withoutExtension(addr); bare != addr {
keys = append(keys, bare)
}
if d := domainOf(addr); d != "" {
keys = append(keys, "@"+d)
// The domain as written, folded in ASCII alone: domainOf folds as
// 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 {
if owners, found := b.logins[k]; found {
@@ -380,53 +379,23 @@ func (s *senderSession) judge() (shown, why string, err error) {
default:
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
// field as it came, folded lines and all, and with the leading space each
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with
// OpenDKIM's own parser (mailparse.go). So the address judged here is the
// address OpenDKIM signs for, whatever the display name, a comment or a
// quoted string around it holds; the domain of the key it signs with is
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c).
// The line must be one address in RFC 5322's grammar (net/mail), the
// address a mail program shows; a display name may say anything. A line
// a lenient program could show as another address - "ceo@bank.com
// <me@mine.com>", "ceo@bank.com:me@mine.com", "<b@evil.com, a@mine.com>"
// - is refused (review of stage 2c, F1). The value comes as the client
// wrote it, folded lines and all (assumptions B-PM12). 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 (F2).
raw := s.froms[0]
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))
if perr != nil || len(list) != 1 {
return shown, "it must be one address, as a mail program reads it", nil
}
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)
ok, err := s.bind.Allowed(s.login, seen)
if err != nil {
@@ -452,8 +421,8 @@ func unfold(v string) string {
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
}
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and
// Postfix's table lookups do; strings.ToLower would also fold characters
// asciiLower lower-cases A-Z only, as Postfix's table lookups do for the
// ASCII keys the panel writes; strings.ToLower would also fold characters
// outside ASCII - the Kelvin sign to "k" - into names they are not.
func asciiLower(v string) string {
b := []byte(v)
@@ -571,12 +540,11 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refus
},
Actions: 0, // read-only
// The headers decide; and each header value with its leading space,
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
// 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps the event
// mask a milter of any version asks for (go-milter v0.4.1 says
// version 2), and milter8_header() drops the space only for a milter
// without SMFIP_HDR_LEADSPC.
// as the client wrote it, the bytes the signer reads too (sign.go).
// Postfix 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps
// the event mask a milter of any version asks for (go-milter v0.4.1
// says version 2), and milter8_header() drops the space only for a
// milter without SMFIP_HDR_LEADSPC.
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 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, ""},
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
// Whatever the name, a comment or a quoted local part holds: OpenDKIM
// signs for the address it reads, and that is the one judged.
{"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: the
// 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, ""},
{"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, ""},
@@ -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, ""},
{"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, ""},
{"no local part: OpenDKIM takes the domain for it", "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
// mail program shows another address first (review of stage 2c, F1).
{"no local part", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
// Lines a lenient mail program could show as another address
// (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"},
{"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"},
@@ -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"},
{"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
// to ASCII (F3): OpenDKIM would not sign it, and it is not the
// application's.
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "a mail program would show another address"},
// to ASCII (F3): it is not the 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 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"},
@@ -134,14 +133,14 @@ func TestSenderCheck(t *testing.T) {
{"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 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"},
// Past 256 bytes OpenDKIM matches its SigningTable against a cut
// "user@domain" - which could end in another tenant's domain.
{"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"},
// The key is the login's, so a long quoted local part naming another
// domain picks nothing: OpenDKIM's 256-byte cut went with it.
{"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"},
{"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"},
{"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
// signed with that sender's key: a line the check passes is one OpenDKIM
// reads the application's own address in (the e2e looks at the signature).
// passed as that sender: a line the check passes is one a mail program reads
// as an address of the application's own domain.
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
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 {
continue
}
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" {
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain)
list, err := fromParser.ParseList(unfold(line))
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
// 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) {
bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range passedFromLines(t, "shop.example.org") {
+33 -31
View File
@@ -27,10 +27,11 @@ import (
"github.com/emersion/go-msgauth/dkim"
)
// signHeaders is the set of header names signed when present: OpenDKIM's
// dkim_should_signhdrs[], RFC 6376 § 5.4.1's list (OpenDKIM 2.11.0~beta2,
// libopendkim/dkim.c:223-246), which OpenDKIM signed with until now. Every
// occurrence is signed, in message order, and From once more (signLines).
// signHeaders is the set of header names signed when present: RFC 6376
// § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM
// 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until
// 2.8.0. Every occurrence is signed, in message order, and From once more
// (signLines).
var signHeaders = map[string]bool{
"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,
@@ -49,11 +50,11 @@ var signHeaders = map[string]bool{
// far above Postfix's own bounds on a transaction.
const signLimit = time.Hour
// SignKeys reads the domains the server signs for, their selectors and key
// files from the KeyTable the panel writes (internal/domain, renderTables():
// "<domain> <domain>:<selector>:<keysDir>/<domain>/<selector>.private"), the
// table OpenDKIM reads too until it leaves the image. A key file is read
// when it changes, and only from under keysDir.
// SignKeys reads the names the server signs for and their selectors from the
// selector table the panel writes (internal/domain, renderSelectors(): a line
// "<name> <selector>" each); the key of a name is
// keysDir/<name>/<selector>.private (domain.KeyPath). The table is read again
// when it changes, a key file when it does.
type SignKeys struct {
table, keysDir string
@@ -81,13 +82,13 @@ type fileStamp struct {
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")
// NewSignKeys reads keyTable; keysDir is the directory every key in it must
// be under.
func NewSignKeys(keyTable, keysDir string) *SignKeys {
return &SignKeys{table: keyTable, keysDir: keysDir, keys: map[string]loadedKey{}}
// NewSignKeys reads the selector table at selectors and the keys under
// keysDir.
func NewSignKeys(selectors, keysDir string) *SignKeys {
return &SignKeys{table: selectors, keysDir: keysDir, keys: map[string]loadedKey{}}
}
// 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 {
fi, err := os.Stat(k.table)
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) {
return nil
}
data, err := os.ReadFile(k.table)
if err != nil {
return fmt.Errorf("key table: %w", err)
return fmt.Errorf("selector table: %w", err)
}
rows := map[string]keyRow{}
for n, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
name, value, ok := strings.Cut(line, " ")
// Domain and selector never hold a colon (internal/domain,
// opendkimForbidden); the path takes the rest.
parts := strings.SplitN(value, ":", 3)
if !ok || len(parts) != 3 || !strings.EqualFold(parts[0], name) {
return fmt.Errorf("key table: line %d is not \"<domain> <domain>:<selector>:<path>\"", n+1)
name, selector, ok := strings.Cut(line, " ")
// The two are names of a directory and a file under keysDir: nothing
// that would lead out of it (internal/domain, assertConfigSafe()).
if !ok || !pathToken(name) || !pathToken(selector) {
return fmt.Errorf("selector table: line %d is not \"<name> <selector>\"", n+1)
}
domain, selector, path := strings.ToLower(name), parts[1], parts[2]
// The panel writes exactly this path (renderTables); anything else
// 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}
domain := strings.ToLower(name)
rows[domain] = keyRow{selector, filepath.Join(k.keysDir, domain, selector+".private")}
}
k.rows, k.stamp = rows, stampOf(fi)
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
// (dkim.go, writePrivateKeyPEM()), PKCS#8 as well.
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
// 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
// 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
// nothing).
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
// 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
// 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
+21 -10
View File
@@ -18,8 +18,8 @@ import (
"github.com/emersion/go-msgauth/dkim"
)
// signFixture is a key directory and KeyTable laid out as internal/domain
// writes them (renderTables(), writePrivateKeyPEM(): PKCS#1 PEM) for
// signFixture is a key directory and selector table laid out as
// internal/domain writes them (renderSelectors(), writePrivateKeyPEM(): PKCS#1 PEM) for
// example.org with selector sel, and the DNS record of the key.
type signFixture struct {
dir, table, keysDir string
@@ -35,15 +35,15 @@ func newSignFixture(t *testing.T) signFixture {
t.Fatal(err)
}
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)
}
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)
}
table := filepath.Join(dir, "KeyTable")
if err := os.WriteFile(table, []byte("example.org example.org:sel:"+keyPath+"\n"), 0o640); err != nil {
table := filepath.Join(dir, "selectors")
if err := os.WriteFile(table, []byte("example.org sel\n"), 0o600); err != nil {
t.Fatal(err)
}
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
@@ -247,7 +247,7 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
}{
{"a login with no domain", "stranger", nil},
{"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)
}
}},
@@ -261,12 +261,23 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
if err := os.Rename(filepath.Join(f.keysDir, "example.org", "sel.private"), outside); err != nil {
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)
}
}},
{"a key table that is not one", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o640); err != nil {
{"a name of dots", "app1", func(t *testing.T, f signFixture) {
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)
}
}},
+6 -10
View File
@@ -1,15 +1,11 @@
# 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
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM
# reads at {domain} - {other} in a display name, a comment or a quoted local
# part - and refuses the rest. The unit test
# (internal/milter/sender_test.go) wants every line it passes read at
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants
# 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.
# 2). None may pass as {other}: the check passes a line only when a mail
# program reads it as one address at {domain} - {other} in a quoted display
# name or a comment, or inside a quoted local part - and refuses the rest.
# The unit test (internal/milter/sender_test.go) wants every line it passes
# read so. {OTHER} is {other} in capitals; {other_qp} is {other} with each dot
# escaped as a quoted pair. One Go-quoted string per line.
"b@{other}"
"<b@{other}>"
"\"a@{domain}\"@{other}"
+5 -8
View File
@@ -1,15 +1,12 @@
# 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
# reasoned). One Go-quoted string per line; {domain} is the application's
# domain. Two tests read this file: the check's unit test
# (audit 2026-10-12, stage 2). One Go-quoted string per line; {domain} is the
# application's domain. Two tests read this file: the check's unit test
# (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
# 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
# line here names in a way the check's scan does not see (a space after the
# @) - 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.
# line here names inside a quoted display name - passed by design.
"a@{domain}"
"<a@{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
// 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.
// 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).
type Mailer struct {
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
// 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
// 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
// -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,
-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
TLSServedCertFile string
TLSReloadInterval time.Duration
OpenDKIMSocket string
SignSocket string
SignLocalSocket string
JournalSocket string
SenderSocket string
// 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
// state: a real key in a temporary directory, and a resolver nobody
// listens on, so every check answers at once without leaving the machine.
odk := domain.NewOpenDKIM(t.TempDir())
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
keys := domain.NewKeys(t.TempDir())
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
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.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600
var err error
+18 -14
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -81,13 +83,11 @@ func contains(list []string, want string) bool {
// and its DMARC record says so.
func TestNewDomainHasNoReports(t *testing.T) {
s, _ := hostedStand(t)
// A second OpenDKIM that reloads nothing: the stand's has no supervisor.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
keys := domain.NewKeys(t.TempDir())
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
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"}})
if rec.Code != http.StatusSeeOther {
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).
func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
s, maps := hostedStand(t)
// An OpenDKIM that reloads nothing - the stand has no supervisor - and
// a sampler for the Health page a failure is shown on.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
// Keys of its own, and a sampler for the Health page a failure is shown
// on.
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(t.TempDir()), s.h.apps, "mail")
s.h.machine = &health.MachineSampler{}
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" {
@@ -335,14 +333,20 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
// the way to finish, not "internal error" (audit 2026-10-12, L17).
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
s, _ := hostedStand(t)
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
dir := t.TempDir()
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(dir), s.h.apps, "mail")
d, err := s.h.domains.Add("gone.example")
if err != nil {
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{})
if rec.Code != http.StatusInternalServerError {
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 +
// 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).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
_, 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
// for the mail path and has OpenDKIM and Postfix re-read them - what the
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's
// tables, the sender map, with the inbound relay on its maps and the filter
// for the mail path and has Postfix re-read them - what the first start after
// a restore does (cmd/panel mailStack.Resync): the DKIM selector table (and
// 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
// 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
@@ -187,7 +188,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
run func() error
}
steps := []step{
{"OpenDKIM's tables", h.domains.Resync},
{"the DKIM selector table", h.domains.Resync},
{"the sender map", h.apps.Resync},
}
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
// 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.
func socketsServer(t *testing.T) *Handlers {
t.Helper()
h, _ := settingsServer(t)
h.machine = &health.MachineSampler{}
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.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)
if err != nil {
t.Skipf("unix sockets unavailable here: %v", err)
@@ -56,24 +57,24 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
h := socketsServer(t)
h.cfg.InboundEnabled = true
h.cfg.InboundAntispamMilter = "" // no filter attached
if got := socketsOf(t, h); len(got) != 3 {
t.Fatalf("%d sockets with the filter off, want 3: %+v", len(got), got)
if got := socketsOf(t, h); len(got) != 4 {
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.
h.cfg.InboundEnabled = false
h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1"
got := socketsOf(t, h)
if len(got) != 3 {
t.Fatalf("%d sockets with the inbound relay off, want 3: %+v", len(got), got)
if len(got) != 4 {
t.Fatalf("%d sockets with the inbound relay off, want 4: %+v", len(got), got)
}
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)
}
}
func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
h := socketsServer(t)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -84,7 +85,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
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)
}
for _, s := range got {
@@ -93,7 +94,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
}
}
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)
}
@@ -111,7 +112,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
}
// 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).
func TestFilterDownIsAMissingSocket(t *testing.T) {
h := socketsServer(t)
@@ -133,10 +134,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
} {
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
got := socketsOf(t, h)
if len(got) != 4 {
t.Fatalf("action %q: %d sockets, want 4", tt.action, len(got))
if len(got) != 5 {
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
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)
@@ -147,7 +148,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
}
card := socketCard(t, h)
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
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)
}
@@ -161,7 +162,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
rec := httptest.NewRecorder()
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
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) {
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 {
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 {
c := checksFixture()
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},
}
c.Cert = health.Certificate{
@@ -44,7 +44,7 @@ func healthReading() healthReadings {
Status: health.StatusOK,
}
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."},
}
c.SocketStatus = health.StatusWarn
@@ -73,7 +73,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
h, _ := settingsServer(t)
out := renderHealth(t, h, healthReading(), "")
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",
`action="/server/health/reload"`, `action="/server/health/recheck"`,
`hx-get="/server/health/fragment"`,
@@ -208,7 +208,7 @@ func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
h, _ := settingsServer(t)
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"
d, err := h.store.AddDomain("example.org", "mail")
if err != nil {
@@ -153,7 +153,7 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
h.dns = dnscheck.New([]string{addr})
h.cfg.Hostname = notifyHost
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)
mail, err := h.domains.ServerMail()
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
// the records of Settings and Health come out as "cannot be read";
// 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},
}, password
}
+27 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"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
// answer when some do not; otherwise their names, or - with the inbound spam
// filter among them, when three names no longer fit - that it is included.
// answer when some do not; otherwise their names - the signer's two once -
// 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
for _, s := range sockets {
if s.Present {
answering++
} else if s.Note != "" && s.Name != filterName {
silent = append(silent, s.Name+" · "+s.Note)
} else {
silent = append(silent, s.Name)
}
if s.Note != "" {
if s.Name == filterName {
withFilter = true
}
socketNames = append(socketNames, s.Name)
if !slices.Contains(socketNames, s.Name) {
socketNames = append(socketNames, s.Name)
}
}
socketSub := strings.Join(socketNames, ", ")
switch {
@@ -455,7 +461,8 @@ func (h *Handlers) healthChecks() healthReadings {
cert := health.CheckServedCertificate(h.cfg.TLSServedCertFile, h.cfg.TLSCertFile, h.cfg.TLSReloadInterval, time.Now())
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("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
// 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
// one in INBOUND_ANTISPAM_MILTER as configured; Health and Overview, the only
// pages that show it, are for the global administrator.
func filterSocket(milter, action string) health.Socket {
tempfail := action == "tempfail"
s := health.CheckMilter("Spam filter", milter, tempfail)
s := health.CheckMilter(filterName, milter, tempfail)
s.Note = "inbound"
switch {
case s.Status != health.StatusError && s.Status != health.StatusWarn:
@@ -519,7 +538,7 @@ func queueSummary(out string) string {
func statusFlash(r *http.Request) string {
switch {
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") != "":
return "DNS re-checked."
default:
@@ -13,7 +13,7 @@ import (
func checksFixture() healthReadings {
return healthReadings{
Processes: []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Status: health.StatusError},
},
ProcessError: false,
@@ -24,7 +24,7 @@ func checksFixture() healthReadings {
Machine: health.Machine{Status: health.StatusUnknown},
Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
Sockets: []health.Socket{
{Name: "OpenDKIM", Present: true, Status: health.StatusOK},
{Name: "dkim-signer", Present: true, Status: health.StatusOK},
{Name: "send-log", Present: false, Status: health.StatusWarn},
},
SocketStatus: health.StatusWarn,
@@ -87,7 +87,7 @@ func TestQueueCardValue(t *testing.T) {
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
c := checksFixture()
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-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
}
@@ -98,7 +98,7 @@ func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
}
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},
}
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
// 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.
func NormalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// Domain enforces a strict server-side whitelist for sending-domain names
// (security.md). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only
// (security.md). The result is safe to write verbatim into the DKIM
// selector table and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with NormalizeDomain.
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
// 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
// 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).
func Selector(s string) error {
if s == "" {
+10 -10
View File
@@ -65,10 +65,10 @@ func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
func overviewFixture() *Overview {
cards := []HealthCard{
{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: "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"},
}
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
}
// healthFixture is the mockup's Health: a healthy machine and five processes,
// a certificate close to expiry, the two milter sockets and a good reverse
// healthFixture is the mockup's Health: a healthy machine and four processes,
// a certificate close to expiry, the milter sockets and a good reverse
// record. refresh renders it as the polled fragment.
func healthFixture(refresh bool) *Health {
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"}},
}
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"},
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.Sockets.End = Verdict("ok")
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: "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
// four lines Postfix and OpenDKIM wrote about it.
// four lines Postfix wrote about it.
func outMessageFixture() *OutMessage {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{
@@ -290,7 +291,7 @@ func outMessageFixture() *OutMessage {
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/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)"},
}, "")
}
@@ -389,12 +390,11 @@ func outQueueFixture() *OutQueue {
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
// refusals and the bounce in red.
func systemLogFixture() *SystemLog {
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: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"},
+2 -2
View File
@@ -167,9 +167,9 @@ var outboundTopics = []HelpTopic{
}},
{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 ",
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("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(),
"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;"})
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
}
+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/qmgr[1]: Q1: status=expired, returned to sender": "error",
"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/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",
+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>`,
`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;`,
`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>`,
`href="/outbound/domains/1">Keep it</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.
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", nil))
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"}))
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`,
`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.`,
`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"`,
)
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
@@ -252,8 +252,8 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
h.ProcessError = true
h.ProcessRows = nil
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"},
Detail: "OpenDKIM socket missing: mail cannot leave."}}
h.SocketRows = []SocketRow{{Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", State: Tag{Status: "error"},
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.HostFacts = []Fact{{Label: "Hostname", Value: Plain("(SELFPOST_HOSTNAME is not set)"), Mono: true}}
h.Flash = &Flash{Text: Plain("DNS re-checked.")}
@@ -261,7 +261,7 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
pageHas(t, "Health", out,
`Could not ask supervisord for the process list.`,
`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>`,
`<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{
Kicker: "Server",
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",
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-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")
}
// What a line says is escaped, not interpreted.
+1 -1
View File
@@ -15,7 +15,7 @@
{{template "health_mail" .}}
{{template "box_open" .Configuration}}
{{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 "body_close"}}
{{template "box_close"}}
+11 -8
View File
@@ -66,12 +66,14 @@ type Config struct {
TLSCertFile string
TLSServedCertFile string
TLSReloadInterval time.Duration
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
// connects to. The status page dials them: the first is required for mail
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
// only for the send log (the journal-milter fails open).
OpenDKIMSocket string
JournalSocket string
// SignSocket and SignLocalSocket are the DKIM signer's two milter
// sockets, applications' mail and the panel's own; JournalSocket is the
// journal-milter's. The status page dials them: the signer's are required
// for mail to leave at all (default_action=tempfail), the journal's only
// for the send log (the journal-milter fails open).
SignSocket string
SignLocalSocket string
JournalSocket string
// SenderSocket is the sender check's socket, attached strictly.
SenderSocket string
// 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
// 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
// (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) {
@@ -145,7 +147,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
TLSCertFile: cfg.TLSCertFile,
TLSServedCertFile: cfg.TLSServedCertFile,
TLSReloadInterval: cfg.TLSReloadInterval,
OpenDKIMSocket: cfg.OpenDKIMSocket,
SignSocket: cfg.SignSocket,
SignLocalSocket: cfg.SignLocalSocket,
JournalSocket: cfg.JournalSocket,
SenderSocket: cfg.SenderSocket,
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
# 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:
build:
context: ../sink
+6 -50
View File
@@ -41,13 +41,12 @@ func fromLines(repoRoot, file, domain, other string) ([]string, error) {
return out, nil
}
// testSignedForItsDomainOnly: the sender check and OpenDKIM read From: with
// two parsers, and that they agree was argued, never run (audit 2026-10-12,
// stage 2). Every From: line the check passes for an application is sent as
// that application, and what arrives must be signed for its domain - with a
// signature that validates - and for no other: a line the check reads as the
// application's own and OpenDKIM as another sender's would be signed with
// that sender's key.
// testSignedForItsDomainOnly: every From: line the check passes for an
// application is sent as that application, and what arrives must be signed
// for its domain - with a signature that validates over these headers - and
// for no other (audit 2026-10-12, stage 2). Until 2.8.0 OpenDKIM chose the
// key by the line and this was the check that the two read it alike; the
// panel's signer takes the login's key, and the run holds it to that.
func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-passed.txt", senderDomain, otherDomain)
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
// 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
+5 -4
View File
@@ -44,11 +44,12 @@ func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
return rest
}
// The panel's user reaches all three sockets through the selfpost group,
// as Postfix does: three ok rows and no detail under any.
// The panel's user reaches all four sockets - the signer's two, the
// 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")
if n := strings.Count(sockets, `<tr><td>`); n != 3 {
t.Fatalf("Milter sockets has %d rows, want 3:\n%s", n, sockets)
if n := strings.Count(sockets, `<tr><td>`); n != 4 {
t.Fatalf("Milter sockets has %d rows, want 4:\n%s", n, sockets)
}
if strings.Contains(sockets, `sp-muted sp-small">`) {
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()
defer func() {
// 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",
"chown -R root:root /data && chmod -R a+rwX /data").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
}
// checkBothSigners is the oracle of docs/plans/panel-dkim-signing.md, stage
// 1: the panel's signer and OpenDKIM both sign (OpenDKIM after the panel in
// the chain, so its signature is the top one - build/postfix-config.sh);
// the two must agree on what they sign - the same h= and bh= - and each
// must verify with dkimpy (python3-dkim in the sink image), an
// implementation that is neither go-msgauth, the panel's library, nor
// OpenDKIM. name is the file in the sink's /mail.
func checkBothSigners(s *stack, name string, raw []byte, domain string) error {
// checkPanelSignature is the oracle of docs/plans/panel-dkim-signing.md: the
// message carries one DKIM signature, the panel's, for domain - signed as
// OpenDKIM signed until 2.8.0 (relaxed/simple, rsa-sha256, From over-signed)
// - and dkimpy (python3-dkim in the sink image), an implementation that is
// not go-msgauth, the panel's library, verifies it. Stage 1 compared it with
// OpenDKIM's on every message before OpenDKIM left. name is the file in the
// sink's /mail.
func checkPanelSignature(s *stack, name string, raw []byte, domain string) error {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parse the delivered message: %w", err)
}
var sigs []map[string]string
for _, v := range msg.Header["Dkim-Signature"] {
tags := dkimTags(v)
if tags["d"] == domain {
sigs = append(sigs, tags)
sigs := msg.Header["Dkim-Signature"]
if len(sigs) != 1 {
return fmt.Errorf("%d DKIM signatures, want 1, the panel's", len(sigs))
}
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 {
return fmt.Errorf("%d DKIM signatures for %s, want 2 (the panel's and OpenDKIM's)", len(sigs), domain)
if h := strings.ToLower(tags["h"]); !strings.HasPrefix(h, "from:") || !strings.HasSuffix(h, ":from") {
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"} {
a, b := sigs[0][tag], sigs[1][tag]
if tag == "h" {
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)
}
out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index 0 < '/mail/%s'", name))
if err != nil || !strings.Contains(out, "signature ok") {
return fmt.Errorf("dkimpy: %v\n%s", err, out)
}
return nil
}
+2 -5
View File
@@ -105,7 +105,7 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
if err := checkOpenDKIMLogs(h); err != nil {
if err := checkDKIMTree(h); err != nil {
t.Fatal(err)
}
if err := checkPostfixHardening(h); err != nil {
@@ -274,7 +274,7 @@ func TestE2E(t *testing.T) {
if err := verifyDKIM(raw, senderDomain); err != nil {
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)
}
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) {
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) {
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
// level-2 refusal of the previous step is noticed by the notifier (it looks
// 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
// 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.
@@ -108,7 +108,7 @@ func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
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)
}
if err := verifyDKIM(raw, serverName); err != nil {
+4 -4
View File
@@ -59,8 +59,8 @@ var port25Cases = []port25Case{
// drives it.
{param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true},
// No antispam milter configured: the inbound journal milter
// alone. The submission chain (strict OpenDKIM + journal-milter)
// must not reach inbound mail.
// alone. The submission chain (the strict sender check and
// signer + journal-milter) must not reach inbound mail.
{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",
},
check: []optionCheck{
// Exact equality also covers the absence of OpenDKIM/journal-milter
// sockets — anything else there would fail the comparison.
// Exact equality also covers the absence of the submission
// 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},
},
},
+15 -11
View File
@@ -14,7 +14,7 @@ import (
// excluded: autostart=false, so it rests STOPPED until its first run and
// EXITED after it (supervisor 4.2.5, supervisor/process.py,
// 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
// panel's /overview page) so it works before an administrator account even
@@ -130,16 +130,20 @@ func checkCitedPackageVersions(s *stack) error {
return nil
}
// checkOpenDKIMLogs waits for OpenDKIM's start line in the container's log:
// OpenDKIM logs through syslog(3) only, and /dev/log is the syslog-relay
// program's (audit 2026-10-12, L30). OpenDKIM 2.11.0~beta2,
// opendkim/opendkim.c: syslog(LOG_INFO, "%s v%s starting%s%s%s",
// DKIMF_PRODUCT, ...), DKIMF_PRODUCT "OpenDKIM Filter" (opendkim.h).
func checkOpenDKIMLogs(s *stack) error {
return waitFor("OpenDKIM's start line in the container log", 20*time.Second, 500*time.Millisecond, func() (bool, error) {
out := s.logs("selfpost")
return strings.Contains(out, "opendkim[") && strings.Contains(out, "OpenDKIM Filter v"), fmt.Errorf("no OpenDKIM line in the log")
})
// checkDKIMTree: the keys and the selector table are the panel's alone,
// directories 0700 and files 0600 (build/entrypoint.sh; internal/domain,
// Keys) - no other program reads them since OpenDKIM left - and the table
// is there, written before the signer serves (cmd/panel/signer.go).
func checkDKIMTree(s *stack) error {
out, err := s.execIn("selfpost", "sh", "-c",
`test -f /data/dkim/selectors && find /data/dkim ! \( -user panel -group panel \( -type d -perm 0700 -o -type f -perm 0600 \) \)`)
if err != nil {
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
+12 -10
View File
@@ -41,6 +41,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
wildcard := who{sc.appLogin, sc.appPassword, "alerts@" + 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 {
name string
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
// header as in the envelope (assumptions B-PS1; the envelope below).
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
// Since 2.5.2 the address OpenDKIM reads decides (OpenDKIM's parser,
// internal/milter/mailparse.go): another sender's name in the display
// name passes, and is signed for the application's own domain
// (passed_from_lines_signed_for_their_domain_only looks at the key).
// The address a mail program reads decides: another sender's name
// in a quoted display name passes, and is signed for the
// application's own domain - the key is the login's
// (passed_from_lines_signed_for_their_domain_only looks at it).
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true},
// Past 256 bytes OpenDKIM matches its SigningTable against the
// address cut short, which here would end in the other domain's
// name (opendkim.c, dkimf_apply_signtable(), MAXADDRESS).
{"an address longer than OpenDKIM reads", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, false},
// Until 2.8.0 OpenDKIM read an address past 256 bytes cut short, and
// this one would have ended in the other domain's name; refused
// then, it is the application's own address now.
{"a long quoted local part naming another domain", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, true},
} {
token := uniqueToken("from")
res := attemptSend(sendAttempt{
@@ -92,6 +93,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
}
continue
}
refused++
if res.ok() {
t.Errorf("%s: From: %s was accepted", c.name, c.header)
continue
@@ -129,8 +131,8 @@ func testSenderCheck(t *testing.T, sc *scenario) {
if err != nil {
return false, err
}
if n := strings.Count(rows, `">wrong From</span>`); n < 6 {
return false, fmt.Errorf("%d rows tagged wrong From, want 6", n)
if n := strings.Count(rows, `">wrong From</span>`); n < refused {
return false, fmt.Errorf("%d rows tagged wrong From, want %d", n, refused)
}
return true, 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
# each transaction to its own file under /mail, so the test harness can read
# 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)
# 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
+1 -1
View File
@@ -132,7 +132,7 @@ func (s *stack) down() {
// reclaimData chowns/chmods the /data bind mount from inside the still-running
// 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).
func (s *stack) reclaimData() {
_, _ = 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") {
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" {
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'