Compare commits

...
5 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 a658da11f7 audit: stage 2c - the answers, and what they make to fix
test / test (push) Canceled after 0s
docs/assumptions-2026-10-14.md gets its second half: every entry of the
inventory settled in the source of the version the image runs, with the
citation now beside it in the code. 559 verdicts (the documents' claims
that repeat one another answered once): 415 hold, 77 hold in part, 55 do
not hold, 12 need a measurement on the stand.

The plan's heuristics line is done; what the sources contradict becomes
fourteen lines of stage 2c, worst first: rspamd signs a forged verdict for
a client it counts as local; the sender check takes any key of the sender
map where Postfix takes the first; expiry, the forced-expire flag and
Postfix's time units; refusals and recipients matched by what Postfix
really writes; postlogd after a rename; inbound mail over 10 MB refused by
cleanup; master outside supervisord's reach; sasldb written in place;
Health showing a socket file and a certificate Postfix does not serve; the
Origin check refusing every POST where Sec-Fetch-Site is missing; two
processes migrating at once; certbot's symlinks; the documents; and the
measurements. Roadmap 31/81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:41:07 +03:00
mixandClaude Opus 5.5 599149bf83 build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)
Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:39:21 +03:00
mixandClaude Opus 5.5 ad3d834392 store, web: cite the source of what they rely on (assumptions part D)
Comments only, no behaviour changed. modernc.org/sqlite v1.60.1 (the
_pragma list on every connection, busy_timeout first; extended result
codes; a deferred BEGIN; multi-statement Exec) and SQLite 3.53.4's
documentation (transactions, WAL, foreign keys, NOCASE, upsert, julianday,
partial indexes); htmx 2.0.4 src/htmx.js; the Fetch and HTML standards for
Sec-Fetch-Site, Origin, SameSite, __Host-, CSP and defer order; Go's
html/template. Comments the source contradicts are corrected - the driver
does not serialise writes (SetMaxOpenConns(1) does), htmx:load fires per
swapped element, no-store does not keep a page out of history.

Left for their own commits: D-S3 a migration two processes can both run,
D-S20 recipient case, D-B3 Origin: null under Referrer-Policy: no-referrer,
D-H5/H6/H7/H9 (audit M11, stage 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:26 +03:00
mixandClaude Opus 5.5 b113e72c71 milter, postfix, app, domain: cite the source of what they rely on (assumptions part B)
Comments only, no behaviour changed. go-milter v0.4.1 (session.go: one
session per connection, replaced after a non-continue reply; macros per
stage), Postfix 3.7.11 (milter8.c, smtpd_check.c
reject_auth_sender_login_mismatch(), mail_addr_find.c, showq_compat.c,
mail_conf_time.c, mail_params.h), Cyrus SASL 2.1.28 (saslpasswd.c,
plugins/sasldb.c: deleting a missing login is not an error), OpenDKIM
2.11.0~beta2 (opendkim-db.c, opendkim-genkey), Berkeley DB 5.3 db_dump,
rspamd 3.11.0. Comments that claimed what the source denies are corrected
(sasldb keeps the password as given, not hashed; an embedded newline is
kept, not truncated; rspamd sorts symbols by absolute weight); fixtures say
seen or composed. The sender check's From: parsing gets no citations: it is
replaced by the port of OpenDKIM's dkim_mail_parse next.

Contradictions left for their own commits: B-G5 cleanAddress, B-PS1 first
key wins, B-PQ1 the forced-expire flag, B-PQ10/PQ11 Postfix's time units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:12 +03:00
mixandClaude Opus 5.5 ce363f48dd logtail, health, panel: cite the source of what they rely on (assumptions part A)
Comments only, no behaviour changed. Each place the log tailer, Health and
cmd/panel rely on another program now says where that is written, for the
version the image runs: Postfix 3.7.11 (log_adhoc(), msg_logger_print(),
smtpd_whatsup(), cleanup_milter.c, qmgr_active.c, postlogd.c, showq.c,
pipe.c), logrotate 3.21.0, supervisor 4.2.5, proc(5). Measurements stay
measurements and say where and when; test fixtures say whether they were
seen or composed.

Where the source contradicts the code the comment does not bless it but
names the register entry (A-8 expiry, A-12 the ISO stamp no Postfix writes,
A-15 the last recipient, A-21/A-23 sasl_username, A-33 postlogd's old
inode, A-38 a socket file is not a listener, A-65 the certificate served);
those are fixed in their own commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:11 +03:00
115 changed files with 3084 additions and 198 deletions
+15
View File
@@ -36,6 +36,16 @@ ENV DEBIAN_FRONTEND=noninteractive
# Preseed Postfix so its install is non-interactive and yields a working
# main.cf. The real relay configuration is generated by the panel.
# (postfix 3.7.11-0+deb12u1, debian/templates: postfix/main_mailer_type and
# postfix/mailname are the questions the install asks.)
# What else arrives through Depends, and is used here: openssl, by
# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/
# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor
# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb
# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2
# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util
# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist).
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
&& apt-get update \
@@ -65,6 +75,8 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# The id is fixed, and it is the one the group has always had: 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).
# groupadd(8) refuses a --gid already in use, so a package that took 998 would
# fail this build rather than share the group.
RUN groupadd --system --gid 998 selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
@@ -81,6 +93,9 @@ RUN groupadd --system --gid 998 selfpost \
# to write the maildrop directory (internal/notify/mailer.go). It does not let
# the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail.
# (Postfix 3.7.11, conf/postfix-files: postdrop and postqueue are
# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst:
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel
# Runtime directories: milter sockets and the consolidated persistent root.
+6
View File
@@ -8,6 +8,12 @@ lingering "alive" with a dead Postfix/OpenDKIM/panel.
Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens.
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
listener writes READY\\n, reads one header line of key:value tokens whose len
is the payload's length, reads the payload, and answers "RESULT 2\\nOK".
supervisord starts the listener as its own child, and on SIGTERM it shuts down
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
"""
import os
import signal
+14 -1
View File
@@ -62,6 +62,8 @@ esac
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
# chown would leave that mode in place — opendkim then cannot read KeyTable and
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
# mode 0o700".)
chown panel:panel /data
chmod 755 /data
# Restored backups or previously-created state may contain panel-owned files
@@ -76,7 +78,8 @@ find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -ex
# tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup:
# - group `selfpost` + setgid on directories so anything the panel creates
# inherits the shared group OpenDKIM traverses;
# inherits the shared group OpenDKIM traverses (inode(7), "The
# set-group-ID bit": files created in such a directory take its group);
# - private keys and tables group-readable (0640);
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
# daemon comes up cleanly with no domains yet.
@@ -135,6 +138,11 @@ chmod 0640 /data/postfix/sender_login_maps
# directory keeps the shared group on anything created inside it later, and
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
# rotate a log whose directory is writable by a non-root group.
# Postfix 3.7.11: src/util/logwriter.c, logwriter_open_or_die(), creates the
# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate
# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent
# directory has insecure permissions" when the directory's group is not root
# and has write permission.
mkdir -p /data/log
[ -e /data/log/mail.log ] || : > /data/log/mail.log
chown -R postfix:selfpost /data/log
@@ -160,6 +168,7 @@ chmod 2750 /run/opendkim /run/selfpost
# running container, and a restart of the whole container is not the check's
# downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
# Generate the outbound-relay Postfix configuration from the environment (spec
@@ -184,6 +193,10 @@ chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
# (Postfix 3.7.11, conf/postfix-files: $queue_directory root 755, maildrop
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
# directories $mail_owner 700; conf/postfix-script, "check-warn": "not owned by
# root".)
chown root /data/postfix/queue /data/postfix/queue/pid
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+19 -2
View File
@@ -7,18 +7,35 @@
# before the reload is lost. `create` (rather than `nocreate`) matters here
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# postfix, which the unprivileged panel process cannot read — confirmed on a
# live container — so logrotate must be the one to create it.
# live container (date not recorded), and so in the source: Postfix 3.7.11,
# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's
# umask(077), src/master/master.c — so logrotate must be the one to create it.
# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill
# -HUP master); master_conf.c, master_config() -> master_restart_service() ->
# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c,
# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the
# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()).
#
# logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
# often than daily — polling merely bounds how late a legitimate rotation runs.
# (logrotate 3.21.0-1: debian/rules, --with-state-file-path=/var/lib/logrotate/
# status. That file is in the container's own filesystem: a recreated container
# starts without it, and logrotate.c, newState(), records a log it has no state
# for as rotated today, so the first rotation after a recreate comes a day
# later.)
set -eu
CONFIG=/etc/logrotate.d/mail
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
# logrotate refuses configs writable by group or others and exits 0 while
# ignoring them — fail here so supervisord reports the fault.
# ignoring them — fail here so supervisord reports the fault. (logrotate
# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ...
# because it is writable by group or others." and returns 0, not an error; a
# config not owned by root is ignored the same way. "Potentially dangerous
# mode" is only a warning there, for any mode & 07533 other than 0400 - this
# loop chooses to treat it as fatal too.)
logrotate_config_ok() {
mode=$(stat -c '%a' "$CONFIG")
mode=${mode#0}
+3
View File
@@ -4,6 +4,9 @@
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
# writes it as user postfix, the unprivileged panel reads it through the shared
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
# (logrotate 3.21.0, logrotate.8: "create mode owner group" makes the new log
# right after the rotation; "delaycompress" leaves the previous log, .1,
# uncompressed until the next cycle.)
/data/log/mail.log {
daily
rotate 14
+18 -3
View File
@@ -3,11 +3,16 @@
# Signing is strictly per-domain: each sending domain has its own key pair and
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables.
# supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM
# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is
# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(),
# opens SigningTable and KeyTable again with dkimf_db_open().)
#
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
# signs nothing until the first domain is added.
# signs nothing until the first domain is added. (opendkim/opendkim-db.c,
# dkimf_db_open(): a file or refile table is read line by line; an empty file
# is an empty table, not an error.)
Syslog no
UMask 007
@@ -15,15 +20,25 @@ Mode s
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
# local-part for a domain; KeyTable maps each domain to its key and selector.
# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are
# wildcard patterns matched against the From: address, in file order.)
KeyTable /data/opendkim/KeyTable
SigningTable refile:/data/opendkim/SigningTable
# The private keys are owned by `panel` and read by `opendkim` through the shared
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
# key-safety check, so the check is disabled here by design.
# key-safety check, so the check is disabled here by design. (opendkim/
# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the
# executing uid, and a group-readable one must be in a group of no other user -
# `panel` owns these and `selfpost` has three members.)
RequireSafeKeys no
# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in,
# UMask: it applies to the socket); its group is selfpost by the setgid
# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's
# groups (opendkim.conf.5.in, UserID); with Background no and -f
# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself.
Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
UserID opendkim
+5
View File
@@ -11,6 +11,11 @@
# copies - not the mounted files, which a renewer may be rewriting - are a
# certificate and the key that belongs to it, renames both over the targets
# and reloads Postfix. When nothing changed it does nothing.
# (Postfix 3.7.11: smtpd loads the certificate in pre_jail_init(), once per
# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master,
# conf/postfix-script, after which the old smtpd processes exit on the closed
# status pipe, src/master/master_conf.c, master_config() ->
# master_restart_service(), and new ones load the files again.)
#
# Runs under supervisord as root. It sleeps first: at container start the
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure
+128 -20
View File
@@ -23,6 +23,19 @@ set -eu
# splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container.
#
# Postfix 3.7.11, man/man5/master.5, "-o name=value (short form)", NOTE 2: no
# whitespace in a short-form value (the long form "-o { name = value }" would
# keep it; this script does not write it). src/postconf/postconf_edit.c,
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c,
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
#
# master.5, SYNTAX: a line that starts with whitespace continues a logical
# line, so the "-o" line awk inserts below the header belongs to the service.
# A later postconf -F/-P re-parses a matched entry with its continuation lines
# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping
# these options.
master_cf_set_smtp_inet_o() {
param="$1"
value="$2"
@@ -62,11 +75,19 @@ master_cf_set_smtp_inet_o() {
# sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment.
# Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
# looked up under serverFQDN.
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
# relay requires TLS on 465; if these files are absent the master still starts
# but TLS handshakes on 465 fail until they appear.
# but TLS handshakes on 465 fail until they appear. Postfix 3.7.11,
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
# made, and smtpd_proto() then drops each wrapper-mode connection ("Wrapper-mode
# request dropped ... TLS context initialization failed"); every new smtpd
# process loads the files again in pre_jail_init().
#
# Postfix insists the private key is root-owned and mode 0600. The bind mount
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
@@ -102,7 +123,9 @@ JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
# Persistent panel-managed sender map; texthash needs no postmap, so the
# unprivileged panel can rewrite it and just ask for a reload.
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.7.11,
# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read
# once by each process - a reload makes the new processes read it again.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
@@ -151,6 +174,14 @@ MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
# maillog_file lives under the persistent /data bind mount (architecture.md §
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
# /dev/stdout — without /data, `postfix check` rejects the path.
# inet_protocols=all in a bridge network without IPv6: Postfix 3.7.11,
# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to
# the next address when a connection fails - an AAAA that cannot be reached
# costs one failed attempt, then IPv4. Postfix
# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any
# prefix", in every Postfix program that logs, postfix(1) itself included.
postconf -e \
"myhostname=${HOSTNAME_VALUE}" \
"maillog_file=${MAIL_LOG_PATH}" \
@@ -160,7 +191,9 @@ postconf -e \
"inet_interfaces=all" \
"inet_protocols=all"
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
# postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11,
# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script,
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
# image cannot lose the service.
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
@@ -178,7 +211,10 @@ postconf -e \
"smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port.
# credentials are never accepted before TLS is up on any port. Postfix 3.7.11,
# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c,
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \
"smtpd_tls_key_file=${TLS_KEY}" \
@@ -191,6 +227,8 @@ postconf -e \
# uses for sender_login_maps is the BARE login the panel writes into the map;
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# the server realm, which equals the realm the accounts were created under.
# (Postfix 3.7.11, src/smtpd/smtpd_sasl_glue.c, smtpd_sasl_activate(): an empty
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
postconf -e \
"smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \
@@ -200,14 +238,19 @@ postconf -e \
"smtpd_sasl_tls_security_options=noanonymous" \
"broken_sasl_auth_clients=yes"
# Sender binding (the critical anti-spoofing control). texthash resolves the
# full address first, then the "@domain" wildcard, so both address modes work
# from the same map.
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
# up in this map with mail_addr_find(), the order being smtpd's and not
# texthash's: the full address, the address without its extension, then the
# "@domain" wildcard - so both address modes work from the same map (Postfix
# 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch();
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
#
# recipient_delimiter is said here and not left to the distribution's main.cf:
# it is what lets an application listed as invoices@ send as invoices+march@,
# and the panel's sender check makes the same second try for the From: line
# (extensionDelimiter in internal/milter/sender.go) - the two must agree.
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
# strip_addr_internal(), for its second try.)
postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"recipient_delimiter=+" \
@@ -216,7 +259,9 @@ postconf -e \
# Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by
# network. This is what makes an open relay impossible.
# network. This is what makes an open relay impossible. (Postfix 3.7.11,
# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists
# are evaluated in turn and the first REJECT ends them.)
postconf -e \
"smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
@@ -224,7 +269,11 @@ postconf -e \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP. Backstop that keeps working even if the
# journal-milter (level 2) is down.
# journal-milter (level 2) is down. Postfix 3.7.11, src/smtpd/smtpd.c,
# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf
# default, debian/templates "postfix/mynetworks", is loopback only).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s"
@@ -244,6 +293,13 @@ postconf -e \
# 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.7.11: html/MILTER_README.html, "Different settings for different
# Milter applications" (the braces, Postfix 3.0 and later) and "Signing
# internally-generated bounce messages"; src/milter/milter.c, milter_*_event()
# and milter_message(): milters are asked in list order and the first non-empty
# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES
# INT_FILT_CLASS_NONE.
postconf -e \
"milter_protocol=6" \
"milter_default_action=tempfail" \
@@ -254,7 +310,8 @@ postconf -e \
# address. The panel's milters judge every message by that address (an
# application's client-address list) and record it (the inbound journal), and
# the address given once at connect is not there for a connection's later
# messages - see MailFrom in internal/milter/milter.go.
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS.
postconf -e \
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
@@ -264,7 +321,8 @@ postconf -e \
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy
# response time (signing/DB insert are sub-second), so they never fire in normal
# operation.
# operation. The defaults: Postfix 3.7.11, src/global/mail_params.h,
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
postconf -e \
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
@@ -274,6 +332,10 @@ postconf -e \
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot.
# The names smtps, submission and smtp need no /etc/services: Postfix 3.7.11,
# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
# smtps=submissions=465, submission=587" (src/util/find_inet.c,
# find_inet_port() via filter_known_tcp_port()).
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \
"smtps/inet/smtpd_tls_wrappermode=yes" \
@@ -283,7 +345,10 @@ postconf -P \
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# difference is TLS is negotiated via STARTTLS, so require encryption before
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# after disabling it does not leave the port listening.
# after disabling it does not leave the port listening. (Postfix 3.7.11,
# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets
# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c,
# pcf_edit_master(): -MX drops the matched entry, continuation lines included.)
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \
@@ -295,7 +360,9 @@ else
fi
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
# Debian's stock master.cf enables smtp/inet (postfix 3.7.11-0+deb12u1,
# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd",
# uncommented). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
@@ -320,8 +387,15 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# postconf -P refuses them ("unmatched service_name/type") and leaves a
# bare "pipe", which dies with "fatal: missing user= command-line
# attribute" on every report. They go into the service line itself.
# (Seen in 1.7.0 through 1.9.5, date not recorded. Postfix 3.7.11:
# src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes
# "-o name=value" and nothing else; postconf_edit.c warns "unmatched
# service_name/type" for a service master.cf does not have;
# src/pipe/pipe.c, get_service_attr(): "missing user= command-line
# attribute".)
# ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument.
# learns which report address the message came to from its argument
# (man/man8/pipe.8, ${recipient}: as many arguments as recipients).
# One at a time (the 1 before "pipe"): reports are in no hurry, every run
# writes to the database the panel writes to, and a hundred of them in
# parallel - Postfix's default - is what a flood of reports would start.
@@ -332,7 +406,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# command reads from the container's environment is named there.
# export_environment is a list split at whitespace in which Postfix
# expands "$": a path with either would reach the command as another
# path, and it would quietly open a database of its own.
# path, and it would quietly open a database of its own. (Postfix 3.7.11,
# man/man8/pipe.8, export_environment; src/global/mail_params.h,
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The
# parameter is main.cf's, so every Postfix process exports these too.)
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
*[[:space:]]* | *'$'*)
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
@@ -351,7 +428,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi
# Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o.
# master_cf_set_smtp_inet_o (master.5, NOTE 2: "or use commas instead of
# spaces").
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
@@ -369,10 +447,17 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on.
# (Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): after
# the lists, check_recipient_rcpt_maps() runs unless the status is
# REJECT - an OK from check_recipient_access does not skip it.)
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi
# The panel writes the same "[host]:port" into transport and
# tls_policy: the policy lookup key is the verbatim next-hop, port and
# brackets included (Postfix 3.7.11, README_FILES/TLS_README, "TLS
# policy table" note: ":smtp", ":25" and no port are different keys).
postconf -e \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
@@ -389,7 +474,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# domain — polluting Deliveries and letting a forged From: consume that
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
# here: the optional antispam milter, then - with the inbound relay on - the
# panel's inbound journal milter, or nothing at all.
# panel's inbound journal milter, or nothing at all. (Postfix 3.7.11,
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
# src/smtpd/smtpd.c hands its own milter list to cleanup, milter_send(),
# and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.)
INBOUND_MILTERS=""
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
# A glob's "*" matches anything, so the shape alone would pass
@@ -422,6 +510,11 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# never refuses, and must never hold up someone else's mail: accept when
# it is down. A comma, not a space, between the two - see
# master_cf_set_smtp_inet_o.
# Postfix 3.7.11: src/milter/milter.c, milter_message(), sends the
# message to each milter in list order; src/milter/milter8.c,
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to
# the queue file at once, and the next milter8_message() reads that
# file with rec_get(), which follows the added records.
case "${INBOUND_JOURNAL_SOCK}" in
*[!A-Za-z0-9._/-]*)
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
@@ -453,7 +546,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
# into the inbound journal and has to tell the two apart.
# into the inbound journal and has to tell the two apart. (Postfix 3.7.11,
# src/global/mail_task.c, mail_task(): the log tag is syslog_name "/"
# program name. Only smtpd carries it: cleanup and the rest are other
# services and log as postfix/.)
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \
"smtp/inet/syslog_name=postfix/inbound" \
@@ -484,13 +580,23 @@ fi
# Inside a container the chroot buys little (the container is the isolation
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
# own smtps/submission services are already n; this covers the delivery agents
# and the rest.
# and the rest. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff
# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the
# like into the chroot, and nothing runs it in this container. chroot(2) also
# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop:
# ALL - capabilities(7).)
postconf -F "*/*/chroot=n"
# --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
# only — both are safe because TLS is mandatory before auth on every port.
# The file is found by a Debian patch, not by upstream Postfix: postfix
# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in
# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl",
# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
# sasldb_path, mech_list).
mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF
pwcheck_method: auxprop
@@ -500,7 +606,9 @@ mech_list: PLAIN LOGIN
EOF
# Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it.
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal
# also runs "post-install create-missing", which builds the queue tree.)
set +e
check_out=$(postfix check 2>&1)
ec=$?
+4
View File
@@ -39,4 +39,8 @@ for sock in $SOCKS; do
done
echo "postfix-wrapper: milter sockets ready, starting postfix"
# Postfix 3.7.11: postfix(1) execs conf/postfix-script (src/postfix/postfix.c),
# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as
# its child; master then makes itself a session leader (src/master/master.c,
# setsid()). The process supervisord watches is that shell, not master.
exec postfix start-fg
+18 -3
View File
@@ -3,7 +3,10 @@
; 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
; before starting Postfix.
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one
; pass, with no wait for a program to be ready.)
;
; If any managed process exhausts its restart retries (FATAL), the crashexit
; event listener brings the whole container down so Docker's restart policy can
@@ -22,7 +25,9 @@ loglevel=info
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
; unreachable by world; only root and the two SelfPost services are in the
; group. No HTTP auth is configured because access is gated by socket
; permissions, not the network.
; permissions, not the network. (supervisor 4.2.5, docs/configuration.rst,
; [unix_http_server]: chmod and chown set the socket's mode and owner; without
; username and password no authentication is asked.)
[unix_http_server]
file=/run/supervisor.sock
chown=root:selfpost
@@ -45,6 +50,10 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; user=panel also gives the panel its supplementary groups, selfpost and
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; drop_privileges(), os.setgroups() from the user's memberships in
; grp.getgrall().
[program:panel]
command=/usr/local/bin/panel
user=panel
@@ -76,7 +85,10 @@ stderr_logfile_maxbytes=0
; supervisord — over the group-accessible control socket — to run this program,
; which executes the canonical `postfix reload` as root. autostart is off (it
; only runs on demand) and a fast, clean exit is expected, so it never trips the
; crashexit listener.
; crashexit listener. (start-fg: see build/postfix-wrapper.sh. supervisor
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
; never FATAL.)
[program:postfix-reload]
command=/usr/sbin/postfix reload
autostart=false
@@ -120,6 +132,9 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
[eventlistener:crashexit]
command=/usr/local/bin/crashexit.py
events=PROCESS_STATE_FATAL
+16 -3
View File
@@ -34,7 +34,12 @@ func runDMARCIngestMode() error {
}
// Not a report this relay keeps. It is dropped: deferring would have
// Postfix try it again for days, and a bounce would go to an envelope
// sender anyone can write - the report addresses are public.
// sender anyone can write - the report addresses are public. Postfix
// 3.7.11: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
// ... service ...)" (src/global/pipe_command.c, pipe_command();
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
// to the sender (src/qmgr/qmgr_active.c, src/global/bounce.c).
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr)
}
@@ -47,8 +52,13 @@ func runDMARCIngestMode() error {
// what a delivery command wrote - about a hundred bytes - into its own line
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
// so the reason goes out first and bare: no prefix, no date, no recipient,
// all of which that line already has. postlog is no use here: pipe(8) starts
// the command without the group that may write to Postfix's log socket.
// all of which that line already has. Postfix 3.7.11, src/pipe/pipe.c,
// eval_command_status(): " (%.100s)" of the output, which
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
// no use here: pipe(8) starts the command without the group that may write to
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
// user's one group). Whether Debian's postlog is set-gid, which would bring
// the group with it, is not recorded - assumptions A-43.
func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
}
@@ -73,6 +83,9 @@ func logSafe(s string) string {
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Postfix 3.7.11, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
// src/local/command.c sets "RECIPIENT".
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
+3 -1
View File
@@ -99,7 +99,9 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st)
// postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md).
// snapshot for the process lifetime (architecture.md). Postfix 3.7.11,
// postconf(1): without -d or -n it shows main.cf's value or the default,
// and -h drops the "name = " label; values are not $-expanded without -x.
retryPolicy := postfix.LoadRetryPolicy()
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
Hostname: cfg.hostname,
+6 -1
View File
@@ -54,7 +54,9 @@ func listenMilter(socketPath string) (net.Listener, error) {
return nil, err
}
// Clear a stale socket left behind by an unclean shutdown, otherwise the
// listen below fails with "address already in use".
// listen below fails with "address already in use" (unix(7): the socket
// file "must be deleted by the caller when it is no longer needed"; bind(2)
// EADDRINUSE).
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
@@ -67,6 +69,9 @@ func listenMilter(socketPath string) (net.Listener, error) {
// group access. The socket inherits group `selfpost` from the setgid parent
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
// it (connecting to a Unix socket needs write permission on the node).
// unix(7): "connecting to a stream socket object requires write
// permission on that socket"; inode(7): a file made in a set-group-ID
// directory takes the directory's group.
if err := os.Chmod(socketPath, 0o660); err != nil {
ln.Close()
return nil, err
+14 -2
View File
@@ -34,7 +34,12 @@ func main() {
log.SetPrefix("dmarc-ingest: ")
if err := runDMARCIngestMode(); err != nil {
log.Printf("ingest failed: %v", err)
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.7.11,
// src/global/pipe_command.c, pipe_command(): a sysexits code maps
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
// unless the output starts with a "D.S.N text" of its own - the
// log prefix above makes sure it does not.
os.Exit(75)
}
return
}
@@ -200,7 +205,14 @@ func antispamAction(raw string) string {
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account.
// login resolves to the right account. Postfix 3.7.11,
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
// plugins/sasldb.c, sasldb_auxprop_lookup(), and common/plugin_common.c,
// _plug_parseuser(): a login
// without "@" is then looked up in the realm serverFQDN. A SASL_REALM other
// than SELFPOST_HOSTNAME does not follow that - assumptions A-48.
func saslRealm() string {
if r := os.Getenv("SASL_REALM"); r != "" {
return r
+6 -1
View File
@@ -22,7 +22,12 @@ func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
// 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.
// 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 {
+11 -3
View File
@@ -19,8 +19,11 @@ import (
const aliveEvery = 2 * time.Second
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
// socket of its own. Postfix attaches that socket strictly, so while this
// role is not serving, mail is deferred: what that costs is counted here and
// socket of its own. Postfix attaches that socket strictly
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.7.11,
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
// try again later"), so while this role is not serving, mail is deferred:
// what that costs is counted here and
// by the log tailer, and shown on Server > Health
// (docs/plans/from-header-binding.md § Statistics).
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
@@ -131,7 +134,12 @@ func byDay(from, to time.Time) []dayPart {
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
// postfixStarted asks supervisord since when Postfix has been running, again
// and again for postfixAskFor while it does not answer.
// and again for postfixAskFor while it does not answer. supervisor 4.2.5,
// supervisor/rpcinterface.py, _interpretProcessInfo(): the uptime runs from
// the spawn of program "postfix", which is build/postfix-wrapper.sh; it waits
// for the milter sockets (up to MILTER_WAIT_TIMEOUT) before it execs
// "postfix start-fg", so the time given is up to that much early
// (assumptions A-46).
func postfixStarted() postfixState {
deadline := time.Now().Add(postfixAskFor)
for {
@@ -24,6 +24,10 @@ services:
- antispam
antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
# (its Dockerfile, USER 11333:11333).
image: rspamd/rspamd:3.11
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
@@ -17,6 +17,18 @@
# SelfPost reads a verdict only next to a signature it can recompute. The
# signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id.
#
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
# as function(task, common_meta) and returns error, headers to add, headers to
# remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
# "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
custom {
+16 -1
View File
@@ -3,6 +3,11 @@
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
# writes them as six files under data/antispam/, which the fragment mounts at
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
# ev_stat - inotify where the kernel has it, else a stat poll every
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
# internal/inbound writes with.)
#
# Deny wins over allow, whichever entries match. A denied sender is refused
# before anything else is looked at (a prefilter). An allowed one is not
@@ -10,7 +15,17 @@
# first decide - but given a weight no spam score outweighs, which a refusal
# by the deny list still overrides.
#
# A domain entry is that domain, not its subdomains.
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
#
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
# with the rule's action and message, and that action stands whatever the score
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
# puts it into messages.smtp_message, src/libserver/milter.c,
# rspamd_milter_send_task_results(), replies with it.
#
# SelfPost counts how often an entry decided a message by what these rules
# leave behind: the deny message below and the names of the allow symbols.
+9 -1
View File
@@ -11,10 +11,18 @@
# storage detail, not a documented public API — VERIFY the exact path against
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
# before wiring it into the volume mount below.
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
+7
View File
@@ -116,6 +116,13 @@ services:
# FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container
# crash-loops on start.
# Each capability above as capabilities(7) defines it (man7.org,
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
# no-new-privileges only changes execve() (Linux,
# Documentation/userspace-api/no_new_privs.rst): root may still call
# setuid()/setgid() with these capabilities.
security_opt:
- no-new-privileges:true
cap_drop:
+6 -1
View File
@@ -8,7 +8,9 @@
# takes over 80/443 and proxies to the panel over the compose network
# instead). Merge it with the base file — run this from the deploy/ directory
# (Compose resolves every relative path in both files against the directory of
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
#
@@ -54,4 +56,7 @@ services:
- ./nginx/certbot-www:/var/www/certbot
# Renewal twice a day is certbot's own recommended cadence; it no-ops
# until a certificate is within its renewal window.
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
# certificate near expiry.)
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
+2
View File
@@ -17,6 +17,8 @@ server {
server {
listen 443 ssl;
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
http2 on;
server_name mail.example.com;
+7 -2
View File
@@ -6,7 +6,9 @@
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
@@ -25,7 +27,10 @@ services:
# Traefik only proxies the panel; it never sees the mail ports.
# These live on the selfpost service (not traefik's) because the
# docker provider reads routing labels off the container being routed
# to, not off traefik itself.
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
# docker/, "Routing Configuration": "Traefik uses container labels";
# with exposedByDefault=false only containers labelled
# traefik.enable=true.)
- traefik.enable=true
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
- traefik.http.routers.selfpost.entrypoints=websecure
+7
View File
@@ -6,6 +6,13 @@
# by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
#
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
# docker-compose.traefik.yml names.
set -eu
ACME_JSON="${1:?path to acme.json}"
File diff suppressed because it is too large Load Diff
+107 -1
View File
@@ -873,7 +873,7 @@ those after it move up one patch number each.
shared files of lines (`from-lines-passed.txt`, `from-lines-other.txt`)
are kept: the second one's test becomes "never signed for {other}"
rather than "refused". - **Opus**
- [ ] **The heuristics review: every assumption about another component,
- [x] **The heuristics review: every assumption about another component,
answered from its source** (owner, 2026-10-14: "нам нужно полное ревью
кода на предмет всей этой эвристики. Её не должно там, где можно
посмотреть код и ответить точно и однозначно" and "в коде таких наших
@@ -893,6 +893,112 @@ those after it move up one patch number each.
window), the smtpd that outlives a reload (measured, not understood), the
NOQUEUE line's fields (two fixtures carry a sasl_username= Postfix does
not write, one presented as seen on the stand). - **Opus**
*Done 2026-10-14:* the register holds 621 entries and their answers; the
citations are in the code (`ce363f4`, `b113e72`, `599149b`, `ad3d834`).
What the sources contradict is fixed by the lines below, each named by
its register entries; the measurements the sources cannot replace are
the last of them.
- [ ] **Forged filter verdicts signed** (C-R7, B-R1): rspamd 3.11.1's
milter_headers skips its verdict routines - nothing removed, nothing
added - for a client in `local_addrs` or an authenticated one
(`skip_local`, `skip_authenticated`, both on by default), while the
custom signature routine runs for every message; a sender on a private
address, or every sender when Docker's proxy shows the bridge gateway,
gets his own `X-Spamd-Result` signed. Both set to false; a dev trial in
a throwaway rspamd 3.11.1 (`rspamc --ip 10.0.0.5` with a forged header)
shows it removed and rspamd's own added. The image tag pinned to
`3.11.1` (C-R12); the from-rules take the envelope only,
`extract_from = "smtp"` (C-R4); the dead `prefilter = true` lines go
(C-R5). - **Opus**
- [ ] **The sender map read as Postfix reads it** (B-PS1): Postfix takes the
owners of the first key `mail_addr_find()` finds - the address, then
without its extension, then `@domain` - not any key that lists the
login; today an application holding `@domain` may put in `From:` an
address a list-mode application of the same domain owns, which Postfix
refuses it as envelope sender. First key wins; tests with a list and a
wildcard application on one domain. The map parser reads continuation
lines and blank-separated owners as Postfix does (B-PS4). - **Opus**
- [ ] **Queue lifetimes read as Postfix reads them** (A-8, B-PQ1, B-PQ10,
B-PQ11): expiry is one qmgr line per message, `<id>: from=<..>,
status=expired, returned to sender`, without `to=` - the tailer maps it
to every open row of that id, closed as bounced, and the dead
per-recipient `expired` case goes; `postqueue -p` marks a forced-expired
message `#`, which the listing parser keeps; a bare number in a time
parameter takes the parameter's own default unit (d for the lifetimes, h
for delay_warning_time) and `conv_time()` reads one number and one unit.
- **Opus**
- [ ] **Refusals matched by what Postfix writes** (A-21, A-23, A-15, A-24):
smtpd's `milter-reject` line carries `sasl_username=` - the refusal is
matched on the login too, so two applications behind one address sending
as one sender are told apart; `sasl_username=` ends at a comma
(`sasl_sender=` may follow); cleanup's `to=` is the last recipient only,
which the rows built from it say; the `IPv6:` trim goes. - **Opus**
- [ ] **Recipients matched by what Postfix writes** (B-G5, D-S20, A-9,
A-31): go-milter hands the address without brackets and parameters, so
`cleanAddress` cuts no blank; Postfix keeps a recipient's case, so the
match is exact; a rewritten recipient is matched by `orig_to=`; a
recipient given twice is one row (cleanup drops the duplicate). Tests
through go-milter's own client. - **Opus**
- [ ] **The log tailer's rotation and stamps** (A-33, A-12): postlogd writes
to the renamed file until `postfix reload`, so the old file is drained
for a while after the switch, not once; no Postfix writes the ISO stamp
`isoStampRe` was made for - it goes, and the fixtures take the syslog
stamp Postfix 3.7 writes. - **Opus**
- [ ] **Inbound mail over 10 MB** (C-P31): cleanup runs with main.cf's
`message_size_limit`, never set, so 10240000 bytes, whatever port 25's
`-o` says - a 12 MB message to the inbound relay is accepted through
DATA and then refused 552. cleanup gets the largest listener limit; e2e
sends 12 MB inbound. And `local_transport = error:...` (C-P8): an
address literal of the container's own address is local today.
- **Opus**
- [ ] **Supervision as supervisord does it** (C-P42, C-V5, C-V6, C-V10,
B-U2, C-P34): master leaves the shell's process group, so stopping the
program never stops Postfix - the wrapper traps TERM and runs `postfix
stop`; `startsecs` above the milter wait for Postfix and 1 for logrotate
so a failure reaches FATAL (logrotate's not taking the container down);
postfix-reload rests in EXITED after a run; a reload's exit status is
read, not assumed; the dead set-permissions branch goes. e2e: restart
of the program, stop of the container. - **Opus**
- [ ] **sasldb written in place** (C-S3, C-Q3): Cyrus opens sasldb2 without
a Berkeley DB environment, so without locks, and `saslpasswd2` writes in
place; an AUTH or a backup during a change can read it torn. The panel
writes a copy and renames it, and the backup reads it under the same
lock. - **Opus**
- [ ] **Health says what is served** (A-38, A-65, D-P1, D-P3): a socket file
is not a listener - unix sockets are dialled as inet ones are; the
certificate shown is the one Postfix serves (`postconf -h
smtpd_tls_cert_file`), with a renewal waiting named; the wording of
"Postfix defers mail" and of Required TLS (mockup first). - **Opus**
- [ ] **The panel in browsers without Sec-Fetch-Site** (D-B3, D-B13): under
`Referrer-Policy: no-referrer` a form POST carries `Origin: null`, which
the origin check refuses - every POST, sign-in included, fails in Safari
before 16.4 and over plain HTTP at a name; `same-origin` instead, with a
header test and one POST on the stand. A shown-once secret is blanked on
`pagehide`; `Pragma` goes. - **Opus**
- [ ] **Two processes opening an old database** (D-S3): `user_version` is
read outside the transaction, so the panel and dmarc-ingest can both
run one migration; `_txlock=immediate` and the version re-read inside
it; a test with two concurrent opens behind a held lock. - **Opus**
- [ ] **Certificates from certbot** (C-A1, C-Q4, D-guide:258, :291, :722):
certbot's `live/` files are relative links into `archive/`, so mounting
`live/<host>` alone, or linking `certs` to it, leaves Postfix without a
certificate and the backup without it. The deploy fragments mount
`/etc/letsencrypt` read-only and point the paths into it; the backup
follows a link under `certs/` to a regular file. - **Opus**
- [ ] **The rest of what the sources contradict** (C-M7 `<pct>0</pct>`,
C-E2 the e2e zone serial, C-D1 a release gate comparing the image's
package versions with the ones cited, C-K8/K9 and the comments the
answers name), and **the documents**: every claim of architecture.md,
security.md and guide.md the answers find false or partly true is
rewritten as the answers propose (D-arch, D-sec, D-guide). - **Sonnet**
- [ ] **What only the stand can answer** (A-43, C-K5, C-M8, C-X1, D-S7,
D-B16 and the document claims marked so): postlog's set-gid in the
image; the client address Postfix sees through Docker's port publishing
over IPv4 and IPv6; the largest DMARC report received; the public keys
of an ECDSA pair as the reload hook compares them; the longest write
against busy_timeout at the retention limit; the favicon each browser
takes. Each measured, dated and written beside the code it settles.
- **Opus**
- [ ] Docs and wording: help topic sender-check and guide § Who an
application may send as (mockup first), security.md § Mail path,
CHANGELOG. - **Sonnet**
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 30/66 | [audit-2026-10-12.md](audit-2026-10-12.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 31/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
+27
View File
@@ -29,6 +29,12 @@ type SASLDB struct {
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration.
//
// Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
// server's name (common/plugin_common.c, _plug_parseuser()).
func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
}
@@ -46,6 +52,11 @@ func (s *SASLDB) Set(login, password string) error {
if err := validateLogin(login); err != nil {
return err
}
// Cyrus SASL 2.1.28, utils/saslpasswd.c, main(): getopt(), which ends the
// options at "--"; -p reads the password with one read() and no prompt
// (read_password()); -c is SASL_SET_CREATE, which lib/server.c,
// sasl_setpass(), checks against nothing - an existing entry is written
// over (sasldb/db_berkeley.c, _sasldb_putdata(), put with no flags).
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
// -c: create the account / set the password.
// -f: operate on our sasldb2 rather than the system default path.
@@ -62,6 +73,11 @@ func (s *SASLDB) Set(login, password string) error {
// Delete removes an application's SASL account (product.md). A missing account
// is not treated as an error, so deletion is idempotent and safe to retry.
//
// Cyrus SASL 2.1.28, plugins/sasldb.c, sasldb_auxprop_store(): "Deleting
// something which is not there is not an error" - the SASL_NOUSER of
// sasldb/db_berkeley.c, _sasldb_putdata(), becomes SASL_OK, so saslpasswd2 -d
// on a missing login exits 0 (utils/saslpasswd.c, main()).
func (s *SASLDB) Delete(login string) error {
if err := validateLogin(login); err != nil {
return err
@@ -110,6 +126,17 @@ func (s *SASLDB) Secret(login string) (string, error) {
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
//
// Cyrus SASL 2.1.28: sasldb/allockey.c, _sasldb_alloc_key(), builds the key
// with no NUL at its end; plugins/sasldb.c, sasldb_auxprop_store(), stores the
// value with strlen(); lib/server.c, sasl_setpass(), stores the plaintext
// userPassword unless SASL_SET_NOPLAIN (saslpasswd2 -n, which Set does not
// pass). Built against Berkeley DB (m4/sasldb.m4 auto_detect; debian/rules
// records BDB_VERSION). Berkeley DB 5.3 Programmer's Reference, "Dump output
// formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/
// dumpload_format.html): header lines up to HEADER=END, then for a hash or
// btree database key and data lines in turn, each a single space and hex,
// and DATA=END.
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
sc := bufio.NewScanner(bytes.NewReader(dump))
// sasldb2 records are tiny, but raise the line cap so a long hex line is
+4 -2
View File
@@ -66,8 +66,10 @@ func TestSASLDeleteArgs(t *testing.T) {
}
}
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
// same shape `db_dump <sasldb2>` emits.
// makeDump builds a db_dump byte-value document from key/value byte pairs. It
// is composed by hand in the shape the Berkeley DB 5.3 "Dump output formats"
// page gives; a real dump may have more name=value header lines (the
// db_load keywords) before HEADER=END, which the parser skips.
func makeDump(pairs [][2][]byte) []byte {
var b strings.Builder
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
+6 -1
View File
@@ -31,6 +31,9 @@ func invalid(format string, args ...any) error {
// applications to the panel. The store refuses such a pair as well
// (idx_applications_login_nocase); a login imported from another instance is
// not folded, since its SASL account and its client use it as it is.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with each owner by strcasecmp_utf8().
func foldLogin(login string) string {
return strings.ToLower(login)
}
@@ -38,7 +41,9 @@ func foldLogin(login string) string {
// validateLogin enforces a strict server-side whitelist for the SASL login
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
// where '@' separates the user from the realm, so allowing it would change the
// account's identity. Client validation is never trusted.
// account's identity. Client validation is never trusted. (Cyrus SASL 2.1.28,
// common/plugin_common.c, _plug_parseuser(): a login is split into user and
// realm at its first "@", for saslpasswd2's store and for the lookup alike.)
//
// The login is the one piece of user input that is passed to saslpasswd2 as a
// command argument (never through a shell, security.md); this whitelist is what
+8 -2
View File
@@ -88,7 +88,9 @@ type Params struct {
// copy taken then can hold a message twice or not at all, so what it would
// bring back is not the queue. Nor could the panel read it - Postfix keeps
// every queue file readable by its own user alone, which is why the panel's
// backup failed outright from 1.3.0 to 2.0.0. What is not carried over is mail
// backup failed outright from 1.3.0 to 2.0.0 (date not recorded; Postfix
// 3.7.11, conf/postfix-files: active, deferred, incoming and the other queue
// directories are $mail_owner 700). What is not carried over is mail
// still waiting for delivery; a start without a queue directory builds an
// empty one. Whoever needs the queue stops the instance and archives the
// project directory (guide § Taking a full backup).
@@ -380,7 +382,11 @@ func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string)
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
// the panel is writing to the live file under WAL. It returns the snapshot path
// and a cleanup function the caller must defer.
// and a cleanup function the caller must defer. (sqlite.org/lang_vacuum.html,
// "VACUUM INTO": "transactional in the sense that the generated output
// database is a consistent snapshot"; modernc.org/sqlite v1.60.1, sqlite.go,
// applyQueryParams(): each _pragma is run as "pragma <value>" on connect,
// busy_timeout first.)
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
dir, err := os.MkdirTemp("", "selfpost-backup-")
if err != nil {
+2 -1
View File
@@ -49,7 +49,8 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
// Postfix's delivery log and its rotated files: diagnostic output, not
// state, and the bulkiest thing under /data.
// state, and the bulkiest thing under /data. The line is composed by hand;
// only its absence from the archive matters.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
+4
View File
@@ -154,6 +154,10 @@ func extractAggregatePayload(raw []byte) ([]byte, error) {
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
}
// looksLikeAggregate takes a part by its file name or Content-Type. RFC 7489
// § 7.2.1.1: the report is "application/gzip" if compressed and "text/xml"
// otherwise, its name ending ".xml" or ".xml.gz"; zip is outside the RFC and
// is what some reporters send (see decompress).
func looksLikeAggregate(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
+10 -1
View File
@@ -40,6 +40,11 @@ type ParsedRecord struct {
HeaderFrom string
}
// feedbackXML follows RFC 7489, Appendix C. The schema's targetNamespace is
// "http://dmarc.org/dmarc-xml/0.1"; a tag without a namespace matches the
// local name in any namespace (Go, src/encoding/xml/read.go: the namespace is
// compared only when the tag names one), so a report that declares one is
// read the same.
type feedbackXML struct {
XMLName xml.Name `xml:"feedback"`
Metadata struct {
@@ -160,6 +165,9 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
return out, nil
}
// dmarcRecordPasses: RFC 7489, Appendix C, PolicyEvaluatedType - dkim and spf
// are "The DMARC-aligned authentication result"; § 4.2: DMARC passes when one
// supported mechanism passes aligned.
func dmarcRecordPasses(r ParsedRecord) bool {
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
}
@@ -170,7 +178,8 @@ const maxAggregateXML = 8 << 20
// decompress unwraps the container the reporter used. Both are seen in the
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
// endpoints) ship it as a single-entry zip.
// endpoints) ship it as a single-entry zip. (A sighting: where and when not
// recorded. RFC 7489 § 7.2.1.1 names gzip only.)
func decompress(raw []byte) ([]byte, error) {
switch {
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
+3
View File
@@ -11,6 +11,9 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
// sampleXML and the messages in this file are composed by hand, in the shape
// RFC 7489 gives (Appendix C, the schema; § 7.2.1.1, application/gzip and the
// .xml.gz name). The org name is any reporter's; no report was copied.
const sampleXML = `<?xml version="1.0"?>
<feedback>
<report_metadata>
+6
View File
@@ -37,6 +37,12 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
// 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).
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
+22 -1
View File
@@ -175,6 +175,10 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
// 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)
@@ -222,6 +226,13 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
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 {
@@ -240,7 +251,9 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
// 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.
// 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
@@ -268,6 +281,14 @@ func assertConfigSafe(domainName, selector string) error {
//
// 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")
}
+10 -1
View File
@@ -9,13 +9,22 @@ import (
)
// certWarnDays is how close to expiry the certificate has to be before the
// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
// status page complains. certbot renews a 90-day certificate at 30 days left
// - a fixed 30 days before 4.0, a third of the lifetime from 4.0 (certbot
// docs, certbot/docs/using.rst, "Renewing certificates") - so two weeks is
// comfortably past the point where automatic renewal should have happened.
// For a 45-day certificate a third is 15 days, one day ahead of this warning
// (assumptions A-64).
const certWarnDays = 14
// Certificate is the state of the TLS certificate Postfix serves on 465/587
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
// the file is supplied by the reverse proxy through a read-only mount.
// Postfix itself serves the copy build/postfix-config.sh makes in
// /etc/postfix/tls-internal, refreshed by build/postfix-cert-reload.sh up to
// TLS_RELOAD_INTERVAL_SECONDS later, and each smtpd loads it when it starts
// (Postfix 3.7.11, src/smtpd/smtpd.c, pre_jail_init(), TLS_SERVER_INIT) - so
// the mounted file can be newer than what is served (assumptions A-65).
type Certificate struct {
Path string
Subject string
+8 -1
View File
@@ -33,7 +33,10 @@ func TestWorstPicksMostSevere(t *testing.T) {
}
func TestParseProcesses(t *testing.T) {
// Real supervisorctl output: column-aligned, one line per program.
// Real supervisorctl output: column-aligned, one line per program. Where
// and when it was seen is not recorded; its columns are those of
// supervisor 4.2.5 supervisorctl.py _show_statuses() and its FATAL text
// supervisor/process.py's spawnerr.
out := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly (process log may have details)
@@ -165,6 +168,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
postfix RUNNING pid 23, uptime 0:04:08
@@ -189,6 +193,9 @@ postfix FATAL Exited too quickly
}
}
// Composed, after supervisor 4.2.5 rpcinterface.py _interpretProcessInfo()
// (str() of a timedelta). "127:00:01" is not a form supervisor prints - it
// only shows that the parser takes hours past 24.
func TestUptime(t *testing.T) {
for detail, want := range map[string]time.Duration{
"pid 41, uptime 0:03:12": 3*time.Minute + 12*time.Second,
+31 -11
View File
@@ -33,11 +33,14 @@ const (
// status page's answer to "is the server itself under strain", next to the
// component checks that answer "is mail flowing".
//
// The numbers come from the kernel's /proc filesystem, which a container shares
// with its host unless it was started with its own namespaces — so CPU and
// memory describe the host, while /proc/net/dev describes whatever network
// namespace the container is in (its own veth under the default bridge, the
// host's interfaces under network_mode: host).
// The numbers come from the kernel's /proc filesystem. /proc/stat and
// /proc/meminfo are system-wide and no namespace changes them (proc(5):
// "kernel/system statistics", "statistics about memory usage on the
// system"), so CPU and memory describe the host, while /proc/net/dev
// describes whatever network namespace the container is in
// (network_namespaces(7) names /proc/net among what a network namespace
// isolates) - its own veth under the default bridge, the host's interfaces
// under network_mode: host.
type Machine struct {
CPU CPU
Memory Memory
@@ -276,7 +279,9 @@ func (c CPU) contextText() string {
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
// MemFree on a healthy machine is near zero and would report a permanent
// emergency. MemAvailable is the kernel's own estimate of what a new workload
// could actually get.
// could actually get (proc(5), /proc/meminfo, MemAvailable "since Linux
// 3.14": "An estimate of how much memory is available for starting new
// applications, without swapping").
func readMemory(root string) Memory {
var m Memory
fields, err := readMeminfo(root)
@@ -398,11 +403,15 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
continue
}
if fields[0] != "cpu" {
cores++ // cpu0, cpu1, … — one line per core
cores++ // cpu0, cpu1, … — one line per online logical CPU
continue
}
// user nice system idle iowait irq softirq steal (guest fields are
// already counted inside user/nice, so they are left out).
// already counted inside user/nice, so they are left out). Field
// order: proc(5), /proc/stat. That guest time is in user/nice too:
// Linux kernel/sched/cputime.c, account_guest_time() adds it to
// CPUTIME_USER (or CPUTIME_NICE) as well as CPUTIME_GUEST (torvalds/
// linux master read 2026-10-14; the kernel is the host's).
for i, f := range fields[1:] {
if i >= 8 {
break
@@ -428,7 +437,11 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
// readCPUTopology returns physical core and logical thread counts from
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
// back to the cpuN count from /proc/stat.
// back to the cpuN count from /proc/stat. The keys are x86's: Linux
// arch/x86/kernel/cpu/proc.c, show_cpuinfo() prints "processor" and, on SMP,
// "physical id" and "core id" (show_cpuinfo_core(); torvalds/linux master
// read 2026-10-14). proc(5) says only that the items are architecture
// dependent; where they are missing each processor counts as a core.
func readCPUTopology(root string) (cores, threads int) {
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
if err != nil {
@@ -522,7 +535,9 @@ func isZeroLoad(load [3]float64) bool {
}
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
// The file reports kB (kibibytes, despite the label) for these fields.
// The file reports kB (kibibytes, despite the label) for these fields: Linux
// fs/proc/meminfo.c, show_val_kb() prints pages << (PAGE_SHIFT - 10)
// (torvalds/linux master read 2026-10-14).
func readMeminfo(root string) (map[string]uint64, error) {
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
if err != nil {
@@ -557,7 +572,12 @@ func readMeminfo(root string) (map[string]uint64, error) {
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
// name is separated by a colon, which may or may not have a space after it, so
// the split is on the colon and not on whitespace.
// the split is on the colon and not on whitespace. proc(5), /proc/net/dev:
// eight receive columns (bytes first), then the transmit ones (bytes first).
// Linux net/core/net-procfs.c, dev_seq_printf_stats(): "%6s: %7llu ..." in
// master read 2026-10-14; the host's kernel may be older, and the colon is
// all this relies on (net/core/dev.c, dev_valid_name() refuses ':' in a
// device name).
func readNetDev(root string) (map[string]netCounters, error) {
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
if err != nil {
+3 -1
View File
@@ -9,7 +9,9 @@ import (
// fakeProc writes a /proc-shaped directory the sampler can be pointed at, so
// the parsing and the arithmetic are tested against known counters instead of
// whatever the machine running the tests happens to be doing.
// whatever the machine running the tests happens to be doing. The file
// contents in this test are composed, in the layouts proc(5) gives for
// /proc/stat, /proc/meminfo and /proc/net/dev.
func fakeProc(t *testing.T, stat, meminfo, loadavg, netdev string) string {
t.Helper()
dir := t.TempDir()
+3 -1
View File
@@ -16,7 +16,9 @@ import (
// add to a poll.
const milterDialTimeout = time.Second
// CheckMilter probes a milter address in the two forms Postfix takes:
// CheckMilter probes a milter address in the two forms build/postfix-config.sh
// lets INBOUND_ANTISPAM_MILTER take (Postfix 3.7.11, src/milter/milter8.c,
// milter8_connect(), also accepts "local:", which that script refuses):
// unix:/path is stat-ed like any milter socket, and inet:host:port is dialled
// and the connection closed at once, with nothing written. required says what
// a failure costs, as for CheckSocket: an error when mail stops without the
+12 -3
View File
@@ -38,7 +38,9 @@ type Process struct {
// The command takes fixed arguments and no user input, so it never goes through
// a shell (security.md). `supervisorctl status` deliberately exits non-zero when
// some program is not running, so the output is parsed first and the exit status
// only matters when nothing could be parsed from it.
// only matters when nothing could be parsed from it. supervisor 4.2.5,
// supervisor/supervisorctl.py, do_status(): exit status 3 (NOT_RUNNING) when
// any program is in STOPPED_STATES, 4 when supervisord does not answer.
func Processes() ([]Process, error) {
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
out, err := cmd.CombinedOutput()
@@ -54,7 +56,8 @@ func Processes() ([]Process, error) {
// supervisorStates are the state words supervisord prints. Lines whose second
// field is not one of them are not status lines (banners, error text) and are
// skipped, so unexpected output cannot masquerade as a process.
// skipped, so unexpected output cannot masquerade as a process. supervisor
// 4.2.5, supervisor/states.py, ProcessStates.
var supervisorStates = map[string]bool{
"STOPPED": true,
"STARTING": true,
@@ -68,6 +71,10 @@ var supervisorStates = map[string]bool{
// parseProcesses turns supervisorctl's tabular output into Process values. Each
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
// supervisor 4.2.5, supervisor/supervisorctl.py, _show_statuses(): the
// template is "%(namespec)-<maxlen+3>s%(state)-10s%(desc)s" (maxlen at least
// 30, no state word longer than 8), and an unknown name prints
// "<name>: ERROR (no such process)".
func parseProcesses(out string) []Process {
var procs []Process
for _, line := range strings.Split(out, "\n") {
@@ -89,7 +96,9 @@ func parseProcesses(out string) []Process {
// Uptime reads for how long a running program has been up from the detail
// supervisord prints for it: "pid 41, uptime 0:03:12", or with days in front,
// "pid 41, uptime 18 days, 3:02:11".
// "pid 41, uptime 18 days, 3:02:11". supervisor 4.2.5,
// supervisor/rpcinterface.py, _interpretProcessInfo(): 'pid %s, uptime %s'
// with Python's str() of a timedelta from the spawn time ("1 day, ...").
func Uptime(detail string) (time.Duration, bool) {
_, rest, found := strings.Cut(detail, "uptime ")
if !found {
+11 -3
View File
@@ -18,9 +18,17 @@ type Socket struct {
}
// CheckSocket stats a milter socket. required distinguishes the two milters:
// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
// leaving the server, while the journal-milter fails open — mail still goes out,
// only the send log stops being written.
// Postfix attaches OpenDKIM 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.7.11, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
// 4.7.1 Service unavailable - try again later", accept answers nothing).
//
// A socket file that is there is reported as listening. It is not proof of
// it: a unix socket's file outlives the process that bound it unless that
// process unlinks it (unix(7)), so a killed OpenDKIM or panel leaves one that
// refuses every connection - assumptions A-38.
func CheckSocket(name, path string, required bool) Socket {
s := Socket{Name: name, Path: path}
if path == "" {
+17 -1
View File
@@ -33,6 +33,12 @@ type ListStore interface {
// line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads
// a file when it changes. Which list wins when both match is the filter's
// configuration: deny.
//
// rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
// ev_stat (inotify, or a stat poll every map_watch_interval x
// map_file_watch_multiplier = 30 s, conf/options.inc), so a change takes
// effect within that, not at once; docs.rspamd.com/configuration/maps asks
// for the atomic rename atomicfile.Write does.
type Lists struct {
store ListStore
dir string
@@ -157,7 +163,10 @@ func (l *Lists) sync() error {
}
// Values were validated on the way in; this is the writer's own guard
// against a row that got into the table some other way. In a map file a
// newline ends the entry, whitespace starts its value and # a comment.
// newline ends the entry, whitespace starts its value and # a comment
// (rspamd 3.11.1, src/libserver/maps/map_helpers.c,
// rspamd_parse_kv_list()). An IP map takes bare v4 and v6 addresses and
// CIDR networks (docs.rspamd.com/configuration/maps, IP maps).
if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil {
return err
}
@@ -165,6 +174,8 @@ func (l *Lists) sync() error {
}
// The filter runs under another user in another container: the directory
// and the files are readable by anyone. They hold addresses, not secrets.
// (The rspamd image runs as uid 11333: rspamd-docker v3.11.1+3,
// Dockerfile, USER 11333:11333.)
if err := os.MkdirAll(l.dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", l.dir, err)
}
@@ -297,6 +308,11 @@ func normalizeNote(raw string) (string, error) {
// refuses with a message, which the log tailer files as the reply of a
// rejected row. An allow rule adds a symbol named after the kind of entry,
// which the journal milter keeps in the verdict of an accepted row.
//
// rspamd 3.11.1: the deny message reaches Postfix as the milter reply "554
// 5.7.1 <message>" (src/libserver/milter.c, rspamd_milter_send_task_results());
// src/plugins/lua/milter_headers.lua, routine x-spamd-result, writes each
// symbol as "%s(%.2f)[%s]" - name, score, options.
const (
denyReplyMark = "this server's deny list"
allowMark = "SELFPOST_ALLOW_"
+3
View File
@@ -314,6 +314,9 @@ func TestListsUsage(t *testing.T) {
t.Fatal(err)
}
}
// The rows below are composed by hand. Their replies and verdicts follow
// rspamd 3.11.1's forms: "Spam message rejected" is the proxy's default
// reject_message (conf/worker-proxy.inc).
// hour 1: the address and its domain are both on the deny list, and both fit.
row("Spammer@Evil.Example", "192.0.2.9", store.SpamDecisionReject, store.SpamEngineRspamd, denied)
// hour 2: another address of the domain.
+3 -2
View File
@@ -9,8 +9,9 @@ import (
)
// fileID names the file itself - its inode - which a rename keeps and a new
// file does not share. It is part of the log's fingerprint, so two log files
// whose first bytes happen to match are still told apart. The device number
// file does not share (rename(2): the file is moved, other hard links to it
// are unaffected; st_ino in stat(2)). It is part of the log's fingerprint, so
// two log files whose first bytes happen to match are still told apart. The device number
// is left out: it can change across a host's reboot (LVM, btrfs, network
// file systems), and the same log would then pass for a rotated one (review
// of stage 2b).
+38 -10
View File
@@ -45,6 +45,19 @@ type Inbound struct {
// so it counts only for a queue id the port-25 smtpd was seen to open
// (inboundClientRe).
//
// Postfix 3.7.11: smtpd's refusal is src/smtpd/smtpd_check.c, log_whatsup():
// "%s: %s: %s from %s: %s;" then " from=<..>", " to=<..>", " proto=",
// " helo=<..>", nothing after; a milter's at smtpd is src/smtpd/smtpd.c,
// check_milter_reply(), whose smtpd_whatsup() appends " sasl_username=<..>"
// only to an authenticated session (SASL is off on port 25). "name[addr]" is
// state->namaddr, the address without "IPv6:" (smtpd.h,
// SMTPD_BUILD_NAMADDRPORT). cleanup's is src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(); smtpd hands it to the one cleanup service
// (cleanup_service_name, default "cleanup"; src/smtpd/smtpd.c,
// mail_open_stream()). Its to= is the last recipient cleanup saw, not every
// one (src/cleanup/cleanup_addr.c, cleanup_addr_recipient() overwrites
// state->recip) - assumptions A-15.
//
// Both are matched from the head of the line, after the timestamp and host
// name: the tail of such a line is the sender's own words (HELO name,
// addresses), and a pattern that could start anywhere would let him write
@@ -61,7 +74,10 @@ var (
inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`)
// A queue id stops being port 25's when the message is queued (qmgr takes
// it: the filter let it through) or when another listener opens a message
// under the same id - Postfix reuses ids.
// under the same id - Postfix reuses ids. Postfix 3.7.11,
// src/qmgr/qmgr_message.c, qmgr_message_read() calls opened()
// (src/global/opened.c: "%s: from=<%s>, size=%ld, nrcpt=%d") for a queue
// file cleanup has committed, each time it enters the active queue.
inboundForgetRe = regexp.MustCompile(`^\S+ postfix/(?:qmgr\[\d+\]: ([0-9A-Za-z]+): from=|(?:[a-z0-9-]+/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=)`)
)
@@ -77,9 +93,11 @@ const inboundQueueIDs = 4096
// inboundPruneEvery is how many rows the tailer writes before it has the
// journal cut back to its row cap, without waiting for the six-hourly prune.
// Refusals are written at the rate strangers send RCPT TO, which no limit of
// Postfix's counts, so between two prunes the journal would otherwise grow as
// fast as someone cared to make it.
// Refusals are written at the rate strangers send RCPT TO. Postfix's one
// limit on that, smtpd_client_recipient_rate_limit, is per client address and
// off by default (postconf(5), Postfix 3.7.11) and not set here, so between
// two prunes the journal would otherwise grow as fast as someone cared to
// make it.
const inboundPruneEvery = 500
// inboundJournal is the per-run state of the inbound half.
@@ -132,9 +150,11 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
program, queueID, kind, clientIP, reason, from, to := m[1], m[2], m[3], m[4], m[5], m[6], m[7]
// The sender's address is his own text too, and with a quoted local part it
// may hold "> to=<…> proto=…" - which would end the sender early and supply
// the recipient. Such a line shows it: the sender is left with an unclosed
// quote. An address with a quote, a bracket or a space in it is not one the
// journal keeps.
// the recipient (Postfix 3.7.11 logs addresses in external form, the local
// part quoted when it holds spaces or specials: postconf(5)
// info_log_address_format, src/global/info_log_addr_form.c). Such a line
// shows it: the sender is left with an unclosed quote. An address with a
// quote, a bracket or a space in it is not one the journal keeps.
if strings.ContainsAny(from, "\"<> ") || strings.ContainsAny(to, "\"<> ") {
return
}
@@ -178,7 +198,12 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
e.Engine = store.SpamEnginePostfix
}
// "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply
// that starts with 4 asks the sender to come back.
// that starts with 4 asks the sender to come back. Postfix 3.7.11:
// smtpd_check_reject() logs smtpd's whole "%d %s ..." reply through
// log_whatsup(); cleanup_milter_apply() logs a
// milter's from its fifth byte (text = resp + 4), so the enhanced code
// leads - when the milter sent one, which src/milter/milter8.c accepts
// either way for SMFIR_REPLYCODE (assumptions A-18).
if e.Decision == store.SpamDecisionReject && strings.HasPrefix(reason, "4") {
e.Decision = store.SpamDecisionTempfail
}
@@ -238,8 +263,11 @@ func printable(s string, max int) string {
// tailer got to it: after the panel was down the two are hours apart. The
// syslog format carries neither year nor zone - the year is taken from now
// (a stamp that would then lie in the future is last year's), the zone is the
// container's, which is what postlogd wrote it in. A line with no readable
// stamp is dated now.
// container's. Postfix 3.7.11, src/util/msg_logger.c, msg_logger_print(): the
// stamp is made by the process that logs, with localtime(), and postlogd
// writes it unchanged (src/postlogd/postlogd.c, postlogd_service()); that
// process has the container's zone (TZ is in import_environment,
// src/global/mail_params.h). A line with no readable stamp is dated now.
func lineTime(line string, now time.Time) (time.Time, string) {
if m := isoStampRe.FindStringSubmatch(line); m != nil {
rest := strings.TrimSpace(line[len(m[0]):])
+4
View File
@@ -52,6 +52,10 @@ func inboundFixture() (*inboundJournal, *fakeInboundStore) {
var tailNow = time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)
// The mail.log lines in this file are composed by hand, after
// src/smtpd/smtpd_check.c log_whatsup() and src/cleanup/cleanup_milter.c
// cleanup_milter_apply() (Postfix 3.7.11). Their ISO stamp is not a format
// Postfix writes (assumptions A-12); TestLineTime has the syslog one.
const (
head = "2026-10-11T12:00:05.123456+00:00 mail "
unknownRcp = head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<a@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<mx.example.net>"
+19 -1
View File
@@ -27,10 +27,21 @@ import (
// "warning:" stand there too. A queue id is hexadecimal in capitals, or, with
// enable_long_queue_ids, digits and letters without vowels; neither word is
// either.
//
// Postfix 3.7.11, src/global/mail_queue.c, mail_queue_enter(), with
// src/global/mail_queue.h MQID_*: a short id is "%05X" of the microseconds
// and "%lX" of the inode (6 to 21 characters); a long one is the seconds (6+)
// and microseconds (4) in src/global/safe_ultostr.c's vowel-less alphabet, 'z',
// then the inode (12 to 23 characters). Program tag and "[pid]: ":
// src/util/msg_logger.c, msg_logger_print().
var queueLineRe = regexp.MustCompile(`^\S+ ([^\s\[]+)\[\d+\]: ([0-9A-F]{6,24}|[0-9B-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]{10,24}): (.*)`)
// smtpdProgramRe tells an smtpd from the other programs, whatever listener it
// serves: "postfix/smtpd", "postfix/submission/smtpd", "postfix/inbound/smtpd".
// Postfix 3.7.11, src/global/mail_task.c, mail_task(): the tag is
// "<syslog_name>/<process name>"; a master.cf "-o syslog_name=" reaches it
// through mail_params_init() (src/global/mail_params.c) before
// single_server_main() calls maillog_client_init() again.
var smtpdProgramRe = regexp.MustCompile(`^postfix/(?:[a-z0-9-]+/)?smtpd$`)
// port25Program is the smtpd of the inbound relay's listener, which
@@ -42,7 +53,9 @@ const (
// lineGrace is how old a line must be before the prune asks whether a
// send-log row carries its queue id. The first lines of a message are
// logged when it arrives and its row is written at end-of-message; an hour
// is a message body sent slowly.
// is a message body sent slowly. Postfix sets no such bound: smtpd_timeout
// limits each read, not the message (postconf(5) smtpd_timeout,
// smtpd_per_request_deadline, Postfix 3.7.11) - assumptions A-34.
lineGrace = time.Hour
// lineBatch is how many lines are held before they are written without
@@ -69,6 +82,11 @@ type lineKeeper struct {
// a stranger can make Postfix log is not something to store on his say-so
// and prune later. An id leaves when qmgr removes the message or when
// another listener opens a message under it (Postfix reuses ids).
// Postfix 3.7.11: smtpd writes "<id>: client=..." when it opens the queue
// file (src/smtpd/smtpd.c, mail_open_stream()); qmgr writes "<id>: removed"
// when it deletes it (src/qmgr/qmgr_active.c, qmgr_active_done_3_generic());
// short ids repeat (postconf(5) enable_long_queue_ids: the long ones are
// the "non-repeating" kind).
port25 map[string]struct{}
}
+4
View File
@@ -9,6 +9,10 @@ import (
"time"
)
// The mail.log lines in this file are composed by hand. "postfix/submission/
// smtpd" is a tag this image never writes (build/postfix-config.sh sets
// syslog_name on port 25 only); it stands for any "-o syslog_name" listener.
//
// Which lines are kept for a message's page, and under which queue id: the id
// at the head of the line and nothing the far end wrote further along it.
func TestQueueLineHead(t *testing.T) {
+56 -19
View File
@@ -61,7 +61,9 @@ func RetentionInterval() time.Duration {
}
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix.
// It is a var so tests can answer without a running Postfix. Postfix 3.7.11,
// src/showq/showq.c, showq_service(): the listing covers the maildrop,
// active, incoming, deferred and hold queues.
var queueIDs = postfix.QueueIDs
// retryPolicy reads Postfix's queue lifetimes, for counting the rows left
@@ -87,8 +89,14 @@ type RetentionDays func() int
//
// postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK)
//
// The "<queue-id>: to=<addr>, …, status=<word>" shape is specific to the
// delivery agents; qmgr/smtpd/cleanup lines do not match.
// The "<queue-id>: to=<addr>, …, status=<word>" shape is the per-recipient
// result line: Postfix 3.7.11, src/global/log_adhoc.c, log_adhoc() writes
// "%s: to=<%s>", ", orig_to=<%s>" (only when the address was rewritten),
// ", relay=%s", then ", dsn=%s, status=%s (%s)". It is called from sent(),
// defer_append() and bounce_append() in whichever process decides - the
// delivery agents, and qmgr too for recipients it defers or bounces itself
// (src/qmgr/qmgr_defer.c, qmgr_bounce.c); smtpd's and cleanup's own lines
// have no "to=<…>, relay=" after the id.
//
// The recipient runs to the ">, " that comes before relay= (and orig_to=,
// when there is one): a quoted local part may hold a ">", and a recipient cut
@@ -105,7 +113,8 @@ type RetentionDays func() int
// server's reply verbatim, so a greedy match would take the *last* status= on
// the line — and that one can come from the reply text, which the far end
// controls. A bounce whose reply quoted "status=sent" would then be filed as a
// success. The real field is always the first one after to=<…>.
// success. The real field is always the first one after to=<…>: in
// log_adhoc() the reply is the "(%s)" after status=.
var deliveryRe = regexp.MustCompile(`^\S+ [^\s\[]+\[\d+\]: ([0-9A-Za-z]+): to=<(.*?)>, (?:orig_to=<.*?>, )?relay=.*?\bstatus=(\w+)`)
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
@@ -211,7 +220,12 @@ func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}
}
// A message Postfix still works on stays queued until its first retry after
// the queue lifetime, which can be a whole backoff later.
// the queue lifetime, which can be a whole backoff later. Postfix 3.7.11,
// src/qmgr/qmgr_active.c: expiry is decided when an attempt ends
// (qmgr_active_done_25_generic()), and a deferred message waits at most
// maximal_backoff_time (qmgr_active_done_3_generic()). The deferred queue
// is scanned only every queue_run_delay (src/qmgr/qmgr.c), which this
// bound leaves out - assumptions A-30.
policy := retryPolicy()
overLifetime := policy.LongestLifetime() + policy.MaximalBackoff
@@ -253,7 +267,11 @@ func countStuck(st StatusStore, now time.Time, overLifetime time.Duration) {
// message means it will never report anything more about it, so the row can
// only be closed on an assumption; it is closed as a failure rather than a
// success because a delivery the panel cannot evidence must not be shown as
// one. Rows whose message is still in the queue, and every row when the queue
// one. Postfix does leave a recipient without a result line in a few cases of
// its own - postsuper -d (src/postsuper/postsuper.c logs only "removed"), a
// recipient cleanup dropped as a duplicate (src/cleanup/
// cleanup_out_recipient.c, been_here()), one a milter deleted - assumptions
// A-31. Rows whose message is still in the queue, and every row when the queue
// cannot be listed at all, are left exactly as they are.
//
// A closed row whose delivery line is in mail.log or mail.log.1 after all is
@@ -308,6 +326,9 @@ func reconcile(st StatusStore, cutoff time.Time, path string) {
// deliveryInLog says whether the current log or the one rotated last holds a
// delivery line for row. Rows reach the sweep minutes after they were
// written, so the end of those two files is where such a line would be.
// logrotate 3.21.0, logrotate.c, prerotateSingleLog(): the first rotated
// name is "<log>.1", without the compression suffix under delaycompress
// (build/logrotate-mail.conf).
func deliveryInLog(path string, row store.QueuedDelivery) bool {
if path == "" {
return false
@@ -496,7 +517,9 @@ func QueueLines(path, queueID string, n int) ([]string, error) {
}
// mentionsQueueID reports whether a mail.log line is about queueID. Postfix
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…"),
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…";
// Postfix 3.7.11 "%s: ..." in log_adhoc(), smtpd's "%s: client=", qmgr's
// "%s: removed"),
// and the match is anchored on the character before it so a shorter id is not
// found inside a longer one — queue ids are hexadecimal, and one being the tail
// of another is ordinary, not unlikely.
@@ -520,19 +543,22 @@ func isQueueIDByte(b byte) bool {
return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
}
// Timestamps at the head of a mail.log line, in the two formats postlogd
// writes (maillog_file in build/postfix-config.sh).
// Timestamps at the head of a mail.log line (maillog_file in
// build/postfix-config.sh): the one format postlogd writes, and an ISO one.
//
// syslogStampRe is the one that matches in practice today: the format is
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
// image is built on Debian's 3.7 — where the parameter does not exist and the
// only format is syslog's traditional one. It carries no year and no zone, so
// the stamp shown is a wall clock and nothing more, which is all this column
// claims to be.
// syslogStampRe is the one that matches. Postfix 3.7.11,
// src/util/msg_logger.c, msg_logger_print(): every record is stamped
// strftime("%b %d %H:%M:%S ") in the logging process's local time (the day
// zero-padded), then the first label of the host name, and postlogd writes it
// as received (src/postlogd/postlogd.c, postlogd_service()). It carries no
// year and no zone, so the stamp shown is a wall clock and nothing more,
// which is all this column claims to be.
//
// isoStampRe is for the RFC 3339 format that same parameter selects once the
// base image carries a Postfix new enough to offer it. Matching it first costs
// one failed anchor per line and means the upgrade needs no change here.
// isoStampRe was written for an RFC 3339 format said to come with a
// maillog_file_format parameter of Postfix 3.9. No such parameter exists in
// 3.7.11, nor in the 3.12 snapshot of 2026-09-25 (postconf.proto, and
// msg_logger.c still uses the same strftime) - assumptions A-12. No line
// Postfix writes matches it.
var (
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
@@ -563,6 +589,9 @@ func SplitTimestamp(line string) (stamp, rest string) {
// start of a file that changed while the panel was down, or end-of-file on a
// first ever run); it reopens the file when it is rotated (inode change from
// logrotate's create, or truncation from copytruncate) so nothing is missed.
// logrotate 3.21.0, logrotate.c, rotateSingleLog(): without copytruncate the
// file is rename()d and, with create (build/logrotate-mail.conf), a new one
// made by createOutputFile(); with copytruncate it is copied and truncated.
//
// caughtUp is closed after the first read that reaches end-of-file, which is
// the point where every line the panel missed while it was down has been
@@ -685,6 +714,12 @@ func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{
// gained lines between the drain() above and this check, since
// Postfix keeps writing to it until it reloads. Drain it once
// more before switching so nothing in that gap is lost.
// Postfix 3.7.11, src/postlogd/postlogd.c, pre_jail_init():
// maillog_file is opened once per postlogd process, which ends
// at "postfix reload" (README_FILES/MAILLOG_README) or after
// max_idle without a record (src/master/dgram_server.c). Lines
// it writes to the old inode after this switch and before the
// reload are not read - assumptions A-33.
drain()
if err := openAt(0, io.SeekStart); err != nil {
log.Printf("log-tailer: reopen %s: %v", path, err)
@@ -707,7 +742,9 @@ func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{
// lost to a restart and lands on the days it covered. A stretch shorter than
// blindMin is not counted: a rotation leaves the name missing for a tick
// between rename and create, and that loses nothing - the new file is read
// from its start.
// from its start. logrotate 3.21.0, logrotate.c, rotateSingleLog(): the
// rename() and createOutputFile() follow each other in one pass, with no
// script or wait between them (postrotate runs after).
type blindness struct {
add func(from time.Time, d time.Duration) error
since time.Time // start of the current stretch; zero when reading
+9
View File
@@ -14,6 +14,13 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
// The mail.log lines in this file are composed by hand, after the formats the
// code cites (Postfix 3.7.11, src/global/log_adhoc.c and the others), unless a
// comment says where one was seen. Some are shapes Postfix does not write:
// "to=..., status=expired" (qmgr logs expiry once per message as "from=<..>,
// status=expired, returned to sender" - assumptions A-8), ISO stamps
// (msg_logger.c writes "%b %d %H:%M:%S" - A-12), and opendkim lines in
// mail.log (build/opendkim.conf has Syslog no).
func TestParseDelivery(t *testing.T) {
cases := []struct {
name string
@@ -787,6 +794,8 @@ func TestSplitTimestamp(t *testing.T) {
// Copied off the live relay (Postfix 3.7, which has no
// maillog_file_format), so this is the shape the panel actually
// meets: zero-padded day, and the host is the container's name.
// Seen on the live relay, date not recorded. The other cases
// here are composed.
name: "syslog's traditional format as the live relay writes it",
line: "Aug 08 07:26:41 selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
stamp: "Aug 08 07:26:41",
+5 -2
View File
@@ -17,7 +17,9 @@ import (
const (
// fingerprintSize is how many bytes from the head of the log identify it.
// Postfix writes a timestamped line per event, so the first 512 bytes are
// Postfix writes a timestamped line per event (Postfix 3.7.11,
// src/util/msg_logger.c, msg_logger_print(): "%b %d %H:%M:%S" on every
// record), so the first 512 bytes are
// effectively unique per log generation — enough to tell "the file we were
// reading" from "a fresh one created by logrotate while we were down",
// which os.SameFile cannot answer across a restart.
@@ -128,7 +130,8 @@ func (t *tracker) resume(f *os.File) int64 {
// readRotated reads, when the log was rotated while the panel was down, the
// rest of the file the saved place belongs to: logrotate renamed it to
// mail.log.1 (delaycompress leaves it plain), and what Postfix wrote there
// mail.log.1 (delaycompress leaves it plain - logrotate 3.21.0, logrotate.c,
// prerotateSingleLog()), and what Postfix wrote there
// between the panel's stop and the rotation - deliveries, refusals, deferrals -
// was otherwise never read (audit 2026-10-12, stage 2b). It is read from the
// saved offset to its end, line by line through handle, before the new file
+19 -5
View File
@@ -13,12 +13,19 @@ import (
// Postfix's line about a message the journal milter refused at MAIL FROM - a
// level-2 limit, a client address off the application's list - is put on the
// row the milter wrote, so the message's page shows it (audit 2026-10-12,
// stage 2b). The refusal has no queue id and the line no login; it is
// matched to its row by client address, sender and time. As the stand writes
// it:
// stage 2b). The refusal has no queue id; it is matched to its row by client
// address, sender and time. As the stand writes it (e2e stand, date not
// recorded):
//
// postfix/smtpd[1149]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>
//
// Postfix 3.7.11, src/smtpd/smtpd.c, check_milter_reply(): "%s: %s: %s from
// %s: %s;%s" with "NOQUEUE" before the queue file is opened, and
// smtpd_whatsup() for the rest - " from=<..>" first. smtpd_whatsup() also
// ends the line with " sasl_username=<login>" on an authenticated session,
// which the quote above lacks and the regexp does not use (assumptions A-20,
// A-21).
//
// Port 25's smtpd (postfix/inbound/smtpd) is a stranger's and has no such row.
var refusalLineRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: NOQUEUE: milter-reject: MAIL from [^\[\s]*\[([^\]]+)\]: [45]\d\d .*?; from=<([^>]*)>`)
@@ -33,18 +40,25 @@ type RefusalStore interface {
}
// fromRefusalRe is cleanup's line about a message the sender check refused
// for its From: line, as the stand writes it:
// for its From: line, as the stand writes it (e2e stand, 2026-10-12; the
// fixture in sendercheck_test.go):
//
// postfix/cleanup[1339]: E543D3C75DA0: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: director@other.e2e.test - not an address this application sends as; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>
//
// The From: line as the check printed it runs to the last " - " before the
// first semicolon; the reason after it holds neither, and the check prints no
// ";" of the client's (sender.go printable), so that semicolon is Postfix's
// and what follows it - the envelope - is Postfix's own text.
// and what follows it - the envelope - is Postfix's own text. Postfix 3.7.11,
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from
// %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then
// " from=<..>", " to=<..>" - the last recipient only (assumptions A-15).
var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d+\]: ([0-9A-Za-z]+): milter-reject: END-OF-MESSAGE from \S+: 5\.7\.1 From: ([^;]*) - ([^;]*); from=<([^>]*)> to=<([^>]*)>`)
// clientLoginRe is smtpd's line naming the login a queue id was submitted
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
// Postfix 3.7.11, src/smtpd/smtpd.c, mail_open_stream(): ", sasl_sender=<..>"
// follows when the client gave AUTH= on MAIL FROM, and "\S+" then takes
// "login," with the comma (assumptions A-23).
var clientLoginRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=.*?, sasl_username=(\S+)`)
// refusalLogins bounds the queue ids whose login is remembered.
+5
View File
@@ -29,6 +29,11 @@ func (r *refusalProbe) AttachRefusalLine(clientIP, from string, _ time.Time, lin
return true, nil
}
// The mail.log lines in this file are composed by hand, with the e2e stand's
// names and addresses (smtpd.c check_milter_reply(), mail_open_stream();
// cleanup_milter.c cleanup_milter_apply(), Postfix 3.7.11); their ISO stamp
// is not a format Postfix writes (assumptions A-12).
//
// The NOQUEUE line of a refusal at MAIL FROM goes on its row; a line whose
// row is not written yet waits and is tried again; port 25's and other
// lines are not refusals of an application.
+15
View File
@@ -29,6 +29,17 @@ import (
// 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.
//
// Postfix 3.7.11, src/milter/milter8.c: milter8_connect() warns "connect to
// Milter service %s: %m" with the milter's configured name; then
// milter8_comm_error() answers, for default_action=tempfail, "451 4.7.1
// Service unavailable - try again later" - the very text milter8_event()
// gives a milter's own SMFIR_TEMPFAIL. smtpd writes "connect from %s" as it
// takes a client and only then connects to the milters, anew for each client
// (src/smtpd/smtpd.c, smtpd_service(), setup_milters(); milter8_create()),
// one client at a time (src/master/single_server.c); at AUTH a 4xx from the
// milters becomes "454 4.3.0 Try again later" with no line of its own
// (src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd()).
//
// A check that is running and stops answering in the middle of a message -
// its socket accepts, nothing replies - is met by cleanup, not smtpd: past the
// DATA command it is cleanup that talks to the milters. Seen on the e2e stand
@@ -39,6 +50,10 @@ import (
//
// One cleanup process serves message after message and the first warning
// ends the message, so each such warning is a message, counted on its own.
// Postfix 3.7.11, src/milter/milter8.c, milter8_read_resp(): "milter %s:
// can't read %s reply packet header: %m", then milter8_comm_error(), which
// closes the milter's stream and leaves it in the error state for the rest of
// that message.
// Until 2.5.0 only smtpd's lines were read, and this case - the one that
// tells a hung panel from a stopped one - was never counted (audit
// 2026-10-12, M13).
+3 -1
View File
@@ -37,7 +37,9 @@ func (d *deferredStore) AddSenderCheckDeferred(at time.Time, n int64, place stri
// 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.
// not answering. Those - smtpd 1149, 1190 and 1201, the second client of
// 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed,
// after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.7.11).
func TestDeferredCounter(t *testing.T) {
st := &deferredStore{}
c := newDeferredCounter(&SenderCheck{Store: st, Socket: "/run/selfpost/sender.sock"})
+29 -1
View File
@@ -33,6 +33,11 @@ type InboundStore interface {
// Like the send-log milter it only watches: every callback continues, and
// Postfix attaches the socket with default_action=accept, so
// neither an error here nor the panel being down holds up inbound mail.
//
// Postfix 3.7.11, src/milter/milter.c, milter_message(): the milters are asked
// in turn while the reply is null, so one that refuses is the last asked;
// src/milter/milter8.c, milter8_comm_error(): default_action=accept gives no
// reply when this milter cannot answer.
type inboundSession struct {
milter.NoOpMilter
rec InboundStore
@@ -85,6 +90,17 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons
// filter's headers arrive here. The filter replaces headers of these names, so
// the last one read is its own - when it is up. Whether it was is decided at
// end-of-message, by the signature.
//
// Postfix 3.7.11, src/milter/milter.c, milter_message(), hands each milter the
// queue file from the start of the content; src/milter/milter8.c,
// milter8_message(), reads it with rec_get(), which follows the pointer
// records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after
// the last header for a header the milter before added.
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routines x-spamd-result
// and x-rspamd-action: remove = 0, every instance
// (src/libserver/milter.c, rspamd_milter_remove_header_safe()), and the new
// one added at the end (order -1, ADDHEADER) - when skip_wanted() does not
// skip the routine (skip_local, skip_authenticated).
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
switch textproto.CanonicalMIMEHeaderKey(name) {
case "Subject":
@@ -112,6 +128,10 @@ var signatureHeader = textproto.CanonicalMIMEHeaderKey(SignatureHeader)
// filter, in hex. The key is used as the text it is stored as. A signature
// fits one queue id, so the header going on to the upstream with the mail
// gives nothing to whoever reads it there.
//
// Measured, not read: what rspamd 3.11 writes for a key and a queue id was
// taken in a throwaway container on 2026-10-12
// (TestFilterSignatureIsWhatTheFilterWrites).
func FilterSignature(key, queueID string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(queueID))
@@ -130,6 +150,10 @@ func (s *inboundSession) signed(queueID string) bool {
// Body fires at end-of-message: the message is about to be queued for its
// upstream. "Continue" lets it through as "accept" would, and keeps this
// session for the connection's next message.
//
// The queue id: Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
// "i", answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). The
// rest as session.Body in milter.go.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
// The queue id is known only now, and with it whether the verdict headers
// read on the way here are the filter's.
@@ -196,6 +220,9 @@ const (
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
const ListSymbolPrefix = "SELFPOST_"
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routine x-spamd-result:
// string.format('default: %s [%.2f / %.2f]', ...), then one
// '%s(%.2f)[%s]' per symbol, joined by '; '.
var (
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
rspamdScore = regexp.MustCompile(`\[(-?[0-9]+(?:\.[0-9]+)?) / (-?[0-9]+(?:\.[0-9]+)?)\]`)
@@ -267,7 +294,8 @@ func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) {
// ServeInbound runs the inbound journal milter on ln until ctx is cancelled.
// key is what the external filter in front of it signs messages with, or
// empty when none is attached (INBOUND_ANTISPAM_MILTER): no verdict is then
// read at all. It returns nil on a clean shutdown.
// read at all. It returns nil on a clean shutdown. What it relies on of
// go-milter and Postfix is cited at Serve in milter.go.
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
srv := &milter.Server{
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
+8 -4
View File
@@ -34,7 +34,9 @@ func (f *fakeInbound) IsInboundDomain(name string) (bool, error) {
return f.domains[name], nil
}
// A header of the kind the documented rspamd sidecar adds.
// A header in the format of the documented rspamd sidecar, composed by hand:
// rspamd 3.11's milter_headers.lua writes the same fields, but sorts the
// symbols by absolute weight (sort_by = 'score'), which this order is not.
const spamdResult = "default: False [7.20 / 15.00];\n\tBAYES_SPAM(5.10)[100.00%];\n\tMIME_GOOD(-0.10)[text/plain];\n\tARC_NA(0.00)[];\n\tRCVD_COUNT_ZERO(0.00)[0];\n\tR_DKIM_NA(2.20)[]"
// message replays one message through s: envelope, headers in order, end.
@@ -233,9 +235,11 @@ func TestInboundAcceptsWhenTheStoreFails(t *testing.T) {
}
}
// rspamd writes symbols heaviest first, so an allow entry's -100 comes last.
// Filter lists counts an entry's matches by finding that symbol in the journal:
// it is kept whatever else there is.
// A composed header with the allow entry's -100 behind six others. rspamd
// 3.11 itself sorts by absolute weight and would put it first
// (milter_headers.lua, x-spamd-result, sort_by = 'score'); the order here is
// the hard case for the reader. Filter lists counts an entry's matches by
// finding that symbol in the journal: it is kept whatever else there is.
func TestRspamdVerdictKeepsTheListSymbols(t *testing.T) {
result := "default: False [-91.00 / 15.00]; SYM_A(3.00)[]; SYM_B(2.00)[]; SYM_C(1.50)[]; SYM_D(1.00)[]; SYM_E(0.80)[]; SYM_F(0.50)[]; SYM_G(0.20)[]; ARC_NA(0.00)[]; SELFPOST_ALLOW_FROM(-100.00)[billing@vendor.example]"
_, detail := rspamdVerdict("no action", result)
+4
View File
@@ -12,6 +12,10 @@ import (
// against the limit forever — a fail-closed drift this milter must not have.
// The TTL is generously longer than any realistic DATA transfer, so a message
// still being received is never dropped from the count.
//
// go-milter v0.4.1, server.go, type Milter: no callback for the end of a
// connection; session.go, HandleMilterCommands(), returns on SMFIC_QUIT or a
// read error without calling the backend.
const reservationTTL = 10 * time.Minute
// reservation is one message that passed the level-2 check and has not been
+58 -1
View File
@@ -9,6 +9,10 @@
// or Accept so a failure of this milter can never block the relay. Postfix is
// configured with default_action=accept for this milter's socket, so even a
// crash or hang fails open (architecture.md § Mail path).
//
// Postfix 3.7.11, src/milter/milter8.c, milter8_comm_error(): a milter that
// cannot be reached, or does not answer within milter_*_timeout, gives no
// reply with default_action=accept - the message goes on.
package milter
import (
@@ -45,6 +49,12 @@ type Store interface {
// the queue-id at end-of-message. go-milter creates one session per
// connection; a connection may carry several messages, so per-message fields
// are reset at MailFrom (the start of every transaction).
//
// go-milter v0.4.1, session.go, Process(), case CodeMacro: every macro packet
// replaces the map; server.go, Serve(): NewMilter() for each connection.
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(): a macro packet, empty
// or not, goes before every event (milter.c, milter_macro_lookup(), never
// returns null).
type session struct {
milter.NoOpMilter
rec Store
@@ -56,7 +66,9 @@ type session struct {
// clientIP is the client's address. Postfix gives it at Connect, once per
// connection, and again with every MAIL FROM as the {client_addr} macro
// (milter_mail_macros in build/postfix-config.sh). The second is the one
// that can be relied on: see MailFrom.
// that can be relied on: see MailFrom. Postfix 3.7.11,
// src/smtpd/smtpd_milter.c, smtpd_milter_eval(): {client_addr} is
// state->rfc_addr (see clientAddr).
clientIP string
login string
@@ -68,6 +80,11 @@ type session struct {
// Connect captures the client IP of a connection's first message. It is not
// enough on its own - see MailFrom.
//
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_proto(): milter_conn_event() is
// given state->addr, the address without "IPv6:". go-milter v0.4.1,
// session.go, case CodeConn: net.ParseIP of it - nil for "unknown" and for a
// family that sends no address.
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
if addr != nil {
s.clientIP = addr.String()
@@ -92,6 +109,17 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// client address: the level-2 limit and an application's client-address list
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
// one connection.)
//
// go-milter v0.4.1, session.go, HandleMilterCommands(): after a reply whose
// Continue() is false (response.go: accept, discard, reject, tempfail) the
// backend is replaced by NewMilter(). Postfix 3.7.11, src/milter/milter8.c,
// milter8_conn_event(): CONNECT goes once per milter connection.
// {auth_authen}: src/smtpd/smtpd_milter.c, smtpd_milter_eval(), gives
// state->sasl_username, null without authentication, and then milter.c,
// milter_macro_lookup(), sends no such macro.
// The tempfail: milter8.c, milter8_event(), SMFIR_TEMPFAIL is "451 4.7.1";
// src/smtpd/smtpd.c, mail_cmd(), replies it and calls mail_reset() - the
// transaction ends, the connection goes on.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
if ip := clientAddr(m); ip != "" {
@@ -114,6 +142,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
// RcptTo records each recipient. Postfix calls this once per recipient, which
// is what lets the journal keep a separate row per (queue-id, recipient).
//
// Postfix 3.7.11, src/smtpd/smtpd.c, rcpt_cmd(): milter_rcpt_event() once per
// RCPT TO; a recipient Postfix refused goes only to a milter that asked for
// SMFIP_RCPT_REJ, which Serve's Protocol does not.
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
return milter.RespContinue, nil
@@ -132,6 +164,12 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
// rows are written. The reply is "continue", which at end-of-message lets the
// message through just as "accept" does - and, unlike it, leaves this session
// in place for the connection's next message (see MailFrom).
//
// go-milter v0.4.1, session.go, case CodeEOB: Body(). Postfix 3.7.11,
// src/global/mail_params.h: DEF_MILT_EOD_MACROS "i", which
// src/cleanup/cleanup_milter.c, cleanup_milter_eval(), answers with the queue
// id. src/milter/milter8.c, milter8_event(): SMFIR_CONTINUE returns the
// milter's default reply, none, and the message goes on.
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
// The rows are in the send log now, so the stored count sees this message
@@ -143,6 +181,11 @@ func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
// Abort ends the current transaction without an end-of-message (client RSET, or
// Postfix rejecting the message for its own reasons). No send-log row will be
// written, so the level-2 slots this message held must go back.
//
// Postfix 3.7.11, src/smtpd/smtpd.c: rset_cmd() and the refusals call
// mail_reset(), which calls milter_abort() (src/milter/milter8.c,
// milter8_abort(): SMFIC_ABORT); so do the end of every transaction and the
// end of the session in smtpd_proto().
func (s *session) Abort(m *milter.Modifier) error {
s.releaseReservations()
s.rcpts = nil
@@ -154,6 +197,10 @@ func (s *session) Abort(m *milter.Modifier) error {
// multi-character macro names in curly braces (e.g. {auth_authen}) while
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
// Postfix sends verbatim, so a lookup must try both forms.
//
// Postfix 3.7.11, src/milter/milter.c, milter_macro_lookup(): each name is
// sent as milter_*_macros spells it. go-milter v0.4.1, session.go, case
// CodeMacro: m.macros[name] = value, the name as received.
func macro(m *milter.Modifier, name string) string {
if v, ok := m.Macros[name]; ok {
return v
@@ -164,6 +211,10 @@ func macro(m *milter.Modifier, name string) string {
// clientAddr is the client's address from the {client_addr} macro, or "" when
// Postfix did not send one that reads as an address. An IPv6 address may come
// in Sendmail's form, with "IPv6:" in front.
//
// Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_sockaddr_to_hostaddr():
// rfc_addr is the dotted quad, "IPv6:" + the address for IPv6, a V4-mapped
// address rewritten to IPv4, or "unknown".
func clientAddr(m *milter.Modifier) string {
raw := strings.TrimSpace(macro(m, "client_addr"))
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
@@ -248,6 +299,12 @@ func domainOf(addr string) string {
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
//
// go-milter v0.4.1, server.go: Close() closes the listener and Serve() then
// returns ErrServerClosed. Postfix 3.7.11, src/milter/milter8.c,
// milter8_message(): SMFIP_NOBODY leaves out only the body chunks - headers,
// end-of-headers and end-of-body (milter8_eob()) still go; milter8_event()
// takes a reply code (SMFIR_REPLYCODE) whatever the action mask.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
flight := &inflight{} // shared: the level-2 window spans all connections
srv := &milter.Server{
+5 -3
View File
@@ -197,9 +197,11 @@ func TestSessionResetsBetweenMessages(t *testing.T) {
}
}
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
// {i} for some versions), so the milter must resolve those too — this is the
// case the SASL-less spike missed and that produced empty app_login at first.
// Postfix sends each macro name as milter_*_macros spells it (src/milter/
// milter.c, milter_macro_lookup()): {auth_authen} in braces, and {i} too where
// a configuration spells it so; the macros here are composed. The milter must
// resolve those too — this is the case the SASL-less spike missed and that
// produced empty app_login at first.
func TestBracedMacros(t *testing.T) {
rec := &fakeRecorder{}
s := &session{rec: rec}
+51 -1
View File
@@ -35,6 +35,13 @@ import (
// "+extension", the domain. (Postfix compares logins without regard to case;
// the check compares them as written, which is how the panel writes them.) The send-log row of a refusal is written by another goroutine and
// is not waited for.
//
// Postfix 3.7.11, 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.
// 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
@@ -91,6 +98,11 @@ func (b *Bindings) fresh() error {
for _, line := range lines {
// texthash: <key><whitespace><value>, the value a comma-separated
// list of logins (internal/postfix renderSenderLoginMaps).
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key
// ends at the first blank; src/util/readlline.c skips a line whose
// first non-blank is "#"; src/util/dict_ht.c folds the key to lower
// case (DICT_FLAG_FOLD_FIX, set for smtpd_sender_login_maps in
// src/smtpd/smtpd_check.c).
fields := strings.Fields(line)
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
continue
@@ -146,6 +158,10 @@ const extensionDelimiter = "+"
// withoutExtension drops the extension of an address's local part: what
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
// an address whose local part begins with the delimiter has no extension.
//
// Postfix 3.7.11, src/global/strip_addr.c, strip_addr_internal(), and
// split_addr.c, split_addr_internal(): the local part, up to the last "@", is
// cut at its first delimiter, unless that is its first character.
func withoutExtension(addr string) string {
at := strings.LastIndexByte(addr, '@')
if at < 0 {
@@ -184,6 +200,12 @@ func NewSigners(path string) *Signers {
}
// Signs reports whether mail whose From: is at domain gets a signature.
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE: the pattern is a line up to its first blank, "#"
// starts a comment; opendkim/util.c, dkimf_mkregexp(): "^" + the pattern
// ("*" as ".*", "." escaped) + "$", compiled REG_ICASE (DKIMF_DB_FLAG_ICASE,
// opendkim.c). So "*@d" signs user@d in any case, and no subdomain of d.
func (s *Signers) Signs(domain string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -231,6 +253,11 @@ type senderSession struct {
// MailFrom starts a message and forgets the one before it: a connection may
// carry several, and after a temporary failure the library hands the
// connection a new session.
//
// go-milter v0.4.1, session.go, HandleMilterCommands(): a new backend after a
// reply whose Continue() is false - the tempfail of Headers; the "y" reply is
// a CustomResponse whose Continue() is true (response.go), so it keeps this
// one. {auth_authen}: see session.MailFrom in milter.go.
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
@@ -257,6 +284,13 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
}
// Headers is the end of the headers, where the message is judged.
//
// go-milter v0.4.1, session.go, case CodeEOH: Headers(). Postfix 3.7.11,
// src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: a "4"/"5" reply
// whose enhanced code starts with the same digit is the message's reply;
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): a "5" rejects the
// message, and src/smtpd/smtpd.c, common_post_message_handling(), says that
// reply to the client.
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
shown, why, err := s.judge()
if err != nil {
@@ -271,6 +305,8 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil
// cleanup has given the message a queue id by the end of its headers
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the
// refusal among them - are found by it on the message's page.
// Postfix 3.7.11, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
s.record(shown, macro(m, "i"))
// A reply code of its own, so that the application is told what is wrong
// and, the refusal being permanent, does not send the message again.
@@ -289,6 +325,10 @@ func (s *senderSession) judge() (shown, why string, err error) {
if s.login == "" {
// Not reachable while the submission ports demand authentication; a
// message nobody answers for is not signed either way.
// Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): with
// smtpd_delay_reject (default yes) smtpd_client_restrictions
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
return "(none)", "no authenticated application", nil
}
switch len(s.froms) {
@@ -419,6 +459,10 @@ func unquoted(v string) string {
// for the null sender of a bounce an application writes itself, which Postfix
// holds against nobody, the domain the login is bound to. Without it the row
// would belong to no domain and its administrator would never see it.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, generic_checks():
// reject_*_sender_login_mismatch run only "if (state->sender &&
// *state->sender)".
func (s *senderSession) domain() string {
if d := domainOf(s.from); d != "" {
return d
@@ -452,6 +496,11 @@ func isHostByte(c byte) bool {
// printable reduces what a client wrote to printable ASCII without the
// characters that mean something in an SMTP reply or a log line, and cuts it:
// it is said back to the client, logged and stored.
//
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE:
// "%%" becomes "%" and a single "%" is dropped; src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(), logs "<id>: milter-reject: END-OF-MESSAGE from
// <client>: <text>; from=<...> to=<...> ...".
func printable(v string) string {
const max = 120
var sb strings.Builder
@@ -476,7 +525,8 @@ func printable(v string) string {
}
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
// nil: refusals are then not logged.
// nil: refusals are then not logged. What it relies on of go-milter and
// Postfix is cited at Serve in milter.go.
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
var refused chan store.SendLogEntry
if rec != nil {
+9 -6
View File
@@ -13,18 +13,21 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
// senderMaps is a sender_login_maps file as internal/postfix writes it: shop
// and news send as any address of their domains, billing only as two listed
// addresses of example.org, and two applications share shop.example.org.
// senderMaps is a sender_login_maps file composed by hand in the format
// internal/postfix writes (key, blank, comma-separated logins), though not in
// its order (renderSenderLoginMaps sorts the keys): shop and news send as any
// address of their domains, billing only as two listed addresses of
// example.org, and two applications share shop.example.org.
const senderMaps = `@news.example.net news
@shop.example.org shop,shop2
invoices@example.org billing
billing@example.org billing
`
// signingTable is OpenDKIM's SigningTable for the same server: the three
// sending domains, one more that has no application yet, and the server's own
// name.
// signingTable is a SigningTable for the same server, composed by hand: the
// three sending domains, one more that has no application yet, and the
// server's own name. Its key-name column is not what internal/domain
// renderTables writes (the domain name); the check reads only the pattern.
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
*@shop.example.org selfpost._domainkey.shop.example.org
*@example.org selfpost._domainkey.example.org
+3 -1
View File
@@ -5,7 +5,9 @@
# (internal/milter/sender_test.go) wants each refused; 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.
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
# e2e on every run.
"b@{other}"
"<b@{other}>"
"\"a@{domain}\"@{other}"
+2
View File
@@ -8,6 +8,8 @@
# 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.
"a@{domain}"
"<a@{domain}>"
"\"The Shop\" <Orders@{domain}>"
+4
View File
@@ -41,6 +41,10 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
return p.reload()
}
// Postfix 3.7.11, man/man5/access.5: "OK" accepts the address the key
// matches; man/man5/transport.5: "user@domain transport:nexthop", and "a
// non-null transport field with a null nexthop field resets the nexthop
// information to the recipient domain"; relay_domains as in renderInboundMaps.
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
recipients = []byte{}
transport = []byte{}
+12
View File
@@ -55,6 +55,13 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
return p.reload()
}
// Postfix 3.7.11, man/man5/postconf.5: a relay_domains table "is matched when
// a (parent) domain appears as lookup key", the value unused; relay_recipient_maps
// is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c,
// MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain
// transport:nexthop" for that domain. README_FILES/TLS_README: the
// smtp_tls_policy_maps key is the verbatim next-hop; src/tls/tls_level.c names
// the levels none, may and encrypt.
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
@@ -87,6 +94,11 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
// inboundNexthop is the Postfix next-hop [host]:port form. The brackets are
// what disable the MX lookup, so the operator's explicit upstream is used as
// given; they also delimit a bare IPv6 address.
//
// Postfix 3.7.11, src/smtp/smtp_connect.c: no MX lookup for a destination that
// starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host
// a name or an address ("ipv6:" in front is optional), checked by
// valid_hostaddr().
func inboundNexthop(host string, port int) string {
return "[" + host + "]:" + strconv.Itoa(port)
}
+18
View File
@@ -59,6 +59,9 @@ type Binding struct {
// architecture.md § Mail path) — their logins are merged onto a single line as
// a comma-separated list, which is how Postfix expects multiple owners of one
// sender.
//
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the value is split into owners with mystrtok(&cp, CHARS_COMMA_SP).
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings)
if err != nil {
@@ -128,6 +131,12 @@ func appendUnique(list []string, v string) []string {
// end it, the comma that separates values, the colon a lookup key cannot carry,
// and the backslash. A login additionally may not carry "@" — that would make it
// look like an address.
//
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key ends at
// the first blank outside quotes, a backslash escapes the next character;
// src/util/readlline.c: a newline ends the line (one that starts with a blank
// continues the one before); the owners split on comma and blank (see
// RebuildSenderLoginMaps).
const (
senderMapAddressForbidden = " \t\r\n,:\\"
senderMapLoginForbidden = " \t\r\n,:@\\"
@@ -161,6 +170,15 @@ func assertMapSafe(address, login string) error {
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md).
//
// Postfix 3.7.11, conf/postfix-script, start-fg: a process that is not PID 1
// runs "$daemon_directory/master -s" as its child, and src/master/master.c
// puts that master in a session of its own (setsid()). "reload" sends the
// master SIGHUP; src/master/master.c calls master_refresh(), and
// master_conf.c, master_config(), lets the running children "commit suicide
// whenever it is convenient" while new ones get the new configuration; a
// texthash table is read into memory once per process
// (README_FILES/DATABASE_README).
func reloadViaSupervisor() error {
return supervisor.Start("postfix-reload")
}
+8
View File
@@ -50,6 +50,11 @@ var postqueueP = func(ctx context.Context) *exec.Cmd {
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
// truncated says whether there was more.
//
// Postfix 3.7.11, src/postqueue/postqueue.c, show_queue(): any user passes
// authorized_mailq_users (DEF_SHOWQ_ACL static:anyone), and a failure is
// msg_fatal_status() - a non-zero exit with the reason on stderr;
// src/postqueue/showq_compat.c, showq_compat(): "Mail queue is empty".
func listQueue() (text string, truncated bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
defer cancel()
@@ -101,6 +106,9 @@ func Queue() (string, error) {
// queue. A listing cut at the size cap is such an error: the messages past the
// cut are still in the queue, and a backlog that large is the outage the sweep
// exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way.
//
// Postfix 3.7.11, src/showq/showq.c, showq_service(): maildrop, active,
// incoming, deferred and hold are the queues it lists.
func QueueIDs() (map[string]struct{}, error) {
out, truncated, err := listQueue()
if err != nil {
+8 -5
View File
@@ -12,7 +12,8 @@ import (
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
// queue entries.
// queue entries composed by hand after the layout of Postfix 3.7.11's
// src/postqueue/showq_compat.c.
func TestFakePostqueue(t *testing.T) {
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
t.Skip("helper process")
@@ -73,10 +74,12 @@ func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
}
// The reconcile sweep decides whether a message is still Postfix's problem, so
// the parser must pick queue ids out of a real listing and nothing else out of
// it: not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line, which — unlike the recipient lines — starts at the left
// margin just as an entry does.
// the parser must pick queue ids out of a listing and nothing else out of it:
// not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line. The listing is composed by hand. Postfix 3.7.11
// (src/postqueue/showq_compat.c, showq_message()) right-aligns a reason to
// column 76, so it reaches the left margin only when it is 76 characters or
// longer; the one here is put at the margin as the hard case.
func TestParseQueueIDs(t *testing.T) {
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
+6
View File
@@ -12,6 +12,12 @@ import (
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
// it: where it is, and since when.
//
// Postfix 3.7.11, src/postqueue/showq_json.c, format_json(): one object and a
// newline per message - "queue_name", "queue_id", "arrival_time" (seconds,
// %ld), "message_size", "forced_expire", "sender", "recipients" [{"address",
// "delay_reason"}]; the queue names are those of src/global/mail_queue.h that
// src/showq/showq.c, showq_service(), scans.
type QueuedMessage struct {
ID string
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
+3
View File
@@ -6,6 +6,9 @@ import (
"time"
)
// The listing is composed by hand: its first line in the field order of
// Postfix 3.7.11's src/postqueue/showq_json.c, format_json(); the others
// short of fields or broken on purpose.
func TestParseQueueJSON(t *testing.T) {
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
{"queue_name": "active", "queue_id": "5F00AA12", "arrival_time": 1791250000, "message_size": 900, "sender": "", "recipients": [{"address": "b@example.net"}]}
+11
View File
@@ -28,6 +28,10 @@ type RetryPolicy struct {
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
// build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts.
//
// Postfix 3.7.11, src/global/mail_params.h: DEF_QUEUE_RUN_DELAY "300s",
// DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s",
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h".
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
QueueRunDelay: 300 * time.Second,
@@ -57,6 +61,9 @@ var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) {
// Fixed argv, no user input — same pattern as Queue (security.md).
// Postfix 3.7.11, src/postconf/postconf_main.c, pcf_show_parameters():
// named parameters are printed in argument order, with -h one "%s\n"
// value each, as written in main.cf (not expanded without -x).
cmd := exec.Command("postconf", "-h",
"queue_run_delay",
"minimal_backoff_time",
@@ -133,6 +140,10 @@ func (p RetryPolicy) BackoffCap() string {
// larger of the queue lifetime for ordinary mail and for mail from the null
// sender, which an application may submit too. It bounds which stored log
// lines can be about a send-log row (store.SendLogLines).
//
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done(): a message
// expires after var_max_queue_time, or var_dsn_queue_time when its sender is
// null - whoever submitted it (qmgr.c caps the second at the first).
func (p RetryPolicy) LongestLifetime() time.Duration {
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
return p.BounceQueueLifetime
+3
View File
@@ -6,6 +6,9 @@ import (
"time"
)
// The postconf values in these tests are composed by hand: values main.cf
// could hold. Stock Postfix 3.7.11 prints "0h" for delay_warning_time
// (src/global/mail_params.h, DEF_DELAY_WARN_TIME), not "0".
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
+10 -3
View File
@@ -12,6 +12,9 @@ import (
// ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix
// authenticates (one sasldb2 across all domains, architecture.md § Mail path).
// It is unique without regard to case too (migration 0008's NOCASE index):
// Postfix 3.7.11, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch()
// compares the SASL login with the map's owners by strcasecmp_utf8.
var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist.
@@ -74,6 +77,7 @@ type Binding struct {
// login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return Application{}, fmt.Errorf("begin add application: %w", err)
@@ -90,6 +94,8 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
}
return Application{}, fmt.Errorf("insert application: %w", err)
}
// sqlite3_last_insert_rowid() of this connection, the transaction's own
// (https://sqlite.org/c3ref/last_insert_rowid.html).
id, err := res.LastInsertId()
if err != nil {
return Application{}, fmt.Errorf("application id: %w", err)
@@ -110,6 +116,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
// address list atomically (product.md). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update application: %w", err)
@@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string)
return nil
}
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (product.md). Returns ErrApplicationNotFound if no such row existed.
// DeleteApplication removes an application and its addresses (via cascade, see
// DeleteDomain), returning the deleted application so the caller can drop its
// sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id)
if err != nil {
+3 -1
View File
@@ -80,8 +80,10 @@ type DMARCIngestStats struct {
}
// InsertDMARCReport stores a parsed report and its records, replacing any prior
// row with the same reporter/report_id/domain triple.
// row with the same reporter/report_id/domain triple; the prior report's
// records go with it by ON DELETE CASCADE (see DeleteDomain).
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
+12 -2
View File
@@ -128,7 +128,10 @@ func (s *Store) GetDomain(id int64) (Domain, error) {
// DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
// such row existed.
// such row existed. SQLite 3.53.4 acts on ON DELETE only on a connection with
// foreign_keys ON, which Open's DSN sets on every one
// (https://sqlite.org/foreignkeys.html § 2, § 4.3); the rows the cascade
// removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
if err != nil {
@@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) {
}
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict.
// modernc.org/sqlite v1.60.1, conn.go newConn(): extended result codes are on
// for every connection, so Error.Code() (error.go) is the extended code
// (https://sqlite.org/rescode.html#constraint_foreignkey, _unique,
// _primarykey); a unique index - the NOCASE one on logins too - fails as
// SQLITE_CONSTRAINT_UNIQUE.
func isForeignKeyViolation(err error) bool {
var se *sqlite.Error
return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
@@ -197,7 +205,9 @@ func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
// DomainExists reports whether a sending domain with this name is configured.
// Used by the DMARC ingest path to refuse reports for domains this relay does
// not send for (security.md: report XML is attacker-supplied input).
// not send for (security.md: report XML is attacker-supplied input). NOCASE
// folds ASCII letters only (https://sqlite.org/datatype3.html § 7), which is
// all a domain name the panel accepts holds.
func (s *Store) DomainExists(name string) (bool, error) {
var n int
err := s.db.QueryRow(
+2
View File
@@ -48,6 +48,7 @@ type InboundDomain struct {
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
@@ -189,6 +190,7 @@ func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
+4 -1
View File
@@ -72,7 +72,8 @@ func (s *Store) InsertInboundSpam(e InboundSpamEntry) error {
// detail - and says whether it wrote. The log tailer uses it for the lines it
// reads when it starts: after an unclean stop those are lines it may have read
// before, and a refusal read twice is otherwise two rows. e.At must be set; a
// row dated "now" is never the same as another.
// row dated "now" is never the same as another. The check and the insert are
// one statement, and one of no row reports 0 (see CreateGlobalUser).
func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) {
at := e.At.UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
n, _ := res.RowsAffected()
// Oldest by when it happened, not by id: the tailer dates a refusal by its
// log line, so after the panel was down the newest ids are old rows.
// SQLite 3.53.4, https://sqlite.org/lang_select.html § 5: a negative LIMIT
// is no upper bound, so this is every row after the newest keep.
res, err = s.db.Exec(
`DELETE FROM inbound_spam_log WHERE id IN
(SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep)
+6 -1
View File
@@ -137,6 +137,9 @@ func (s *Store) DeleteNotifyState(key string) error {
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
// states of subjects that are gone - a deleted domain - can be found.
// SQLite's substr counts characters of a TEXT value, len(prefix) its bytes
// (https://sqlite.org/lang_corefunc.html#substr): the two agree for the ASCII
// prefixes the callers pass ("dns:", "limit:").
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
rows, err := s.db.Query(
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
@@ -181,7 +184,9 @@ type LimitRefusal struct {
// reason and is still judged the old way.
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
// The status is a literal so that the partial index of migration 0005
// answers the question.
// answers the question: the query's term matches the index's WHERE
// exactly (https://sqlite.org/partialindex.html § 3) when the statement is
// first prepared.
rows, err := s.db.Query(
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
FROM send_log l
+4 -1
View File
@@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
if n <= 0 {
return nil
}
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("sender check deferred: %w", err)
@@ -118,7 +119,9 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
}
// To the fraction of a second the log gives, and of a fixed width so that
// the strings compare as the times do; a re-read log must not move the
// mark back.
// mark back. SQLite 3.53.4, https://sqlite.org/lang_upsert.html § 2: a
// DO UPDATE whose WHERE is false is a no-op, not an error, and excluded.
// names the value the INSERT brought.
stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z")
setting := func(key, value, cond string) error {
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
+23 -4
View File
@@ -47,6 +47,11 @@ type SendLogEntry struct {
// application whose login it is, NULL for a login that is no application's
// (audit 2026-10-12, L12). The domain name of the row stays what the
// envelope said.
//
// The login is matched exactly (BINARY): it reaches the milter and the log in
// the case it is stored in, because Cyrus SASL 2.1.28 looks it up as given -
// lib/canonusr.c _canonuser_internal() only trims spaces and appends a realm,
// sasldb/allockey.c _sasl_alloc_key() copies it byte for byte into the key.
const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)`
// InsertQueued records an accepted message in the send log with status
@@ -108,7 +113,12 @@ func (s *Store) InsertRejected(e SendLogEntry) error {
// first and then writes fails at once with "database is locked" when another
// connection - the dmarc-ingest process opens the same file - commits in
// between, and the line would be lost; a write first takes the write lock,
// and the busy timeout waits for it.
// and the busy timeout waits for it. SQLite 3.53.4, btree.c btreeBeginTrans():
// the busy handler is retried only while the connection has no transaction
// open, so the upgrade of a read transaction fails with SQLITE_BUSY at once -
// also while the other connection merely holds the write lock - and with
// SQLITE_BUSY_SNAPSHOT after it committed (https://sqlite.org/rescode.html#busy_snapshot).
// Every other transaction of this package opens with a write for this reason.
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
@@ -184,7 +194,9 @@ type QueuedDelivery struct {
// about them.
//
// created_at is stored as RFC3339 UTC, so a lexical comparison against the same
// format is chronologically correct.
// format is chronologically correct. SQLite 3.53.4 compares TEXT with the
// BINARY collation, memcmp() (https://sqlite.org/datatype3.html § 7), and
// time.RFC3339 of a UTC time is fixed-width, YYYY-MM-DDTHH:MM:SSZ.
func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) {
rows, err := s.db.Query(
`SELECT id, queue_id, to_addr FROM send_log
@@ -247,6 +259,12 @@ type SendLogRow struct {
// order the rows were written, so two refusals of one second take one line
// each in turn (review of stage 2b). A line already on a row - read again
// after a restart - goes on no other. It reports whether a row took the line.
//
// SQLite 3.53.4: the choice and the write are one UPDATE, a write transaction
// from its start (https://sqlite.org/lang_transaction.html § 2.1); julianday()
// reads YYYY-MM-DDTHH:MM:SSZ, the T and the Z included (lang_datefunc.html
// § 2); NOCASE folds ASCII only (datatype3.html § 7.1); ?1 is bound once and
// used twice (lang_expr.html § 4).
func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) {
res, err := s.db.Exec(
`UPDATE send_log SET refusal_line = ?1 WHERE id = (
@@ -277,7 +295,8 @@ func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line stri
// and nothing is. The domain is the login's, or - when the tailer did not see the login, its
// client= line read before a restart - the envelope's, so the row is still
// its domain's administrator's (review of stage 2b). It reports whether it
// wrote one.
// wrote one: the check and the insert are one statement (see
// CreateGlobalUser), and an INSERT ... SELECT of no row reports 0.
func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) {
stamp := at.UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
// implementing the configurable retention window (architecture.md §
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct.
// same format is chronologically correct (see ListQueuedOlderThan).
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`,
+6 -1
View File
@@ -43,7 +43,12 @@ func cutSendLogLine(line string) string {
// wrote. The tailer calls it with what it read in one pass over the log,
// whether or not send-log rows exist for the queue ids yet: a commit per line
// would be a disk sync per line, on the one connection the journal milter
// writes through.
// writes through. SQLite 3.53.4 as modernc builds it keeps synchronous at FULL
// (SQLITE_DEFAULT_WAL_SYNCHRONOUS 2), and in WAL mode FULL syncs the WAL at
// every commit (https://sqlite.org/wal.html § 2.3, pragma.html#pragma_synchronous).
// The first statement stepped is an INSERT, a write (see UpdateStatus):
// tx.Prepare leaves no transaction open (prepare.c sqlite3InitOne() commits
// the read it opens to load the schema).
//
// With once set, a line is written only if the same line is not there already
// under its queue id. That is for the lines the tailer reads again after an
+6 -1
View File
@@ -30,7 +30,10 @@ func (s *Store) CreateSession(tokenHash string, userID int64, expiresAt time.Tim
// LookupSession returns the session for tokenHash, if any. It does not check
// expiry itself — callers compare ExpiresAt against time.Now() and call
// DeleteSession on an expired row, keeping the read side lock-free.
// DeleteSession on an expired row, keeping the read side lock-free: in WAL
// mode a reader takes no write lock and is not blocked by a writer
// (https://sqlite.org/wal.html § 2.2). Within this process it still waits for
// the one pooled connection (Open).
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
var (
row SessionRow
@@ -99,6 +102,8 @@ func (s *Store) DeleteUserSessions(userID int64) error {
// abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own
// background loop.
// expires_at is RFC3339 UTC text, so < compares moments (see
// ListQueuedOlderThan).
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil {
+2 -1
View File
@@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) {
return value, nil
}
// SetSetting upserts a settings key.
// SetSetting upserts a settings key (https://sqlite.org/lang_upsert.html: on
// a conflict of the key, excluded.value is the value the INSERT brought).
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+7
View File
@@ -85,6 +85,10 @@ const domainByName = "domain_id = (SELECT d.id FROM domains d WHERE d.name = ?)"
// login can be taken again in another domain, and its numbers - statistics,
// the level-2 count - must not start with the old one's (review of audit
// stage 2). ?1 twice; a plain ? after it takes the next number.
// SQLite 3.53.4, https://sqlite.org/lang_expr.html § 4: a plain ? is numbered
// one more than the largest number already assigned; modernc.org/sqlite
// v1.60.1, conn.go bind() and argFinder.find(): ?NNN takes argument NNN, a
// plain ? the argument of its number.
const appByLogin = "app_login = ?1 AND domain_id = (SELECT a.domain_id FROM applications a WHERE a.login = ?1)"
// sendStats counts the rows that match, a condition with one placeholder
@@ -102,6 +106,9 @@ func (s *Store) sendStats(match, ref string, since time.Time, hours float64) (Se
return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err)
}
// The busiest UTC hour: substr counts characters from 1
// (https://sqlite.org/lang_corefunc.html#substr), so 1-13 of an RFC3339
// UTC stamp is YYYY-MM-DDTHH.
var peak int64
err = s.db.QueryRow(
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
+26 -2
View File
@@ -29,13 +29,23 @@ type Store struct {
func Open(path string) (*Store, error) {
// _pragma parameters are applied on every pooled connection by the driver,
// so foreign-key enforcement and WAL survive connection churn.
// modernc.org/sqlite v1.60.1, conn.go newConn() -> sqlite.go
// applyQueryParams(): the _pragma list runs on every new connection,
// busy_timeout first. SQLite 3.53.4: foreign_keys is per connection
// (https://sqlite.org/foreignkeys.html § 2), journal_mode=WAL stays in the
// file (https://sqlite.org/wal.html § 3.3).
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// modernc's driver serializes writes anyway; a small pool avoids
// "database is locked" surprises under WAL.
// One connection: database/sql then runs this process's statements and
// transactions one after another, so none of them meets another's lock.
// The driver does not serialise across connections (modernc.org/sqlite
// v1.60.1, conn.go: the conn's mutex is its own); between processes - the
// dmarc-ingest command opens this file too - SQLite allows one write
// transaction at a time (https://sqlite.org/lang_transaction.html § 2.1)
// and the other waits out busy_timeout.
db.SetMaxOpenConns(1)
s := &Store{db: db}
@@ -83,6 +93,10 @@ func (s *Store) migrate() error {
// against today's files would skip the baseline and leave the panel running
// on a schema it does not know, so such a file is refused outright; nothing
// is migrated from 1.x (docs/schema-migrations.md).
// SQLite 3.53.4: the application id is the header's integer at offset 68
// (https://sqlite.org/fileformat2.html § 1.3.15); btree.c newDatabase()
// zeroes bytes 24-99 of a new file's header, so it reads 0 until set - and
// 1.x never set it.
if version > 0 {
var appID int64
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
@@ -109,12 +123,22 @@ func (s *Store) migrate() error {
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
// One Exec runs the whole file: modernc.org/sqlite v1.60.1, stmt.go
// stmt.exec(), "FALLBACK PATH: Multi-statement script" prepares and
// steps each statement of the tail in turn. It runs with foreign_keys
// ON, which cannot be switched off inside a transaction
// (https://sqlite.org/pragma.html#pragma_foreign_keys): an ADD COLUMN
// with REFERENCES needs a NULL default (lang_altertable.html § 4), and
// a changed collation means a rebuilt table (§ 8).
if _, err := tx.Exec(string(sqlBytes)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
// PRAGMA does not accept a bound parameter, and target is a trusted
// loop index, so formatting it in is safe.
// SQLite 3.53.4, pragma.c sqlite3Pragma(), PragTyp_HEADER_VALUE: the
// value is the literal's text (sqlite3Atoi), written by OP_SetCookie
// under a write transaction, so a rollback takes it back with the rest.
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
tx.Rollback()
return fmt.Errorf("bump schema version for %s: %w", name, err)
+18 -3
View File
@@ -83,6 +83,10 @@ func (s *Store) UserExists() (bool, error) {
// CreateGlobalUser inserts the first global user during setup. The check that
// no user exists yet and the insert are one statement, so two setup requests
// at once cannot both create one (audit 2026-10-12, M7).
// SQLite 3.53.4, https://sqlite.org/lang_transaction.html § 2.1: an INSERT
// starts a write transaction, one at a time, so its SELECT reads what is
// committed; an insert of no row leaves sqlite3_changes() at 0
// (https://sqlite.org/c3ref/changes.html).
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) {
if c.Role == RoleDomain && c.Reach.Empty() {
return 0, ErrEmptyReach
}
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
@@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error {
}
defer tx.Rollback()
// A write first: it takes the write lock, so what is counted below is
// what is committed.
// what is committed. SQLite 3.53.4, https://sqlite.org/lang_transaction.html
// § 2.2: after a deferred BEGIN, a first statement that writes starts a
// write transaction, and § 2.1: there is one at a time. RowsAffected counts
// the row its WHERE found even when no value changes: update.c
// sqlite3Update() writes every such row through sqlite3CompleteInsertion(),
// which marks it counted (OPFLAG_NCHANGE).
// An empty hash keeps the stored one: the form read it when it was
// opened, and the user may have changed it on Account since.
res, err := tx.Exec(
@@ -403,7 +413,9 @@ func (s *Store) SetUserReach(userID int64, reach Reach) error {
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global
// user: the delete and the count are one transaction, so two administrators
// deleting each other at once cannot both succeed (audit 2026-10-12, M7).
// deleting each other at once cannot both succeed (audit 2026-10-12, M7). The
// DELETE comes first and so takes the write lock (see SaveUser); the rows its
// cascade removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteUser(id int64) error {
tx, err := s.db.Begin()
if err != nil {
@@ -517,7 +529,10 @@ func (s *Store) setUserReach(userID int64, reach Reach) error {
// writeUserReach writes a user's reach inside tx. With an All flag set the
// rows of that direction are dropped rather than stored: the flag already
// covers them, and stale rows would come back to life the day the flag is
// cleared.
// cleared. Its first statement is a write (see UpdateStatus). A domain deleted
// meanwhile fails its INSERT at once: SQLite 3.53.4's foreign keys are
// immediate unless declared deferred (https://sqlite.org/foreignkeys.html § 4.2),
// and the code is SQLITE_CONSTRAINT_FOREIGNKEY (isForeignKeyViolation).
func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error {
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
reach.AllDomains, reach.AllInbound, userID); err != nil {
+9
View File
@@ -11,6 +11,9 @@ import (
"golang.org/x/crypto/bcrypt"
)
// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted
// only with Secure, Path=/ and no Domain, from a secure origin - so no other
// host can set or overwrite it, and clearing it needs Secure as well.
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
@@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
case 1:
return token, true
default:
// RFC 6265 § 5.4: the Cookie header carries every cookie that
// matches, a parent domain's of the same name included. The prefixed
// name cannot be planted that way (see sessionCookiePrefixed).
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
@@ -130,6 +136,9 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a
// cross-site request unless that is a top-level navigation with a safe method
// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it.
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: m.sessionCookie(),
+4
View File
@@ -34,6 +34,10 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler {
})
}
// isSessionActivity: a GET from htmx is a background poll - every panel hx-get
// is a load or a poll - and does not renew the session. htmx 2.0.4, src/htmx.js
// getHeaders(): every request it issues carries HX-Request: true (and
// loadHistoryFromServer() sets it too).
func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
}
+4 -1
View File
@@ -31,7 +31,10 @@ func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the
// sliding idle window so the browser drops the cookie no later than the
// server would have expired it anyway.
// server would have expired it anyway. RFC 6265 § 5.2.2: Max-Age sets the
// expiry to now plus that many seconds, and a cookie set again under the same
// name, domain and path replaces the old one (§ 5.3 step 11), so each renewal
// slides it.
func (s *sessionStore) MaxAge() int {
return int(s.idle.Seconds())
}
@@ -92,6 +92,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
f.Username = strings.TrimSpace(r.PostFormValue("username"))
f.Email = strings.TrimSpace(r.PostFormValue("email"))
// An unticked checkbox is not sent at all: absent is off.
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
// a checkbox whose checkedness is false is skipped.
f.Notify = r.PostFormValue("notify") != ""
if f.Username == "" {
f.Username = u.Username
+3
View File
@@ -395,6 +395,9 @@ func (h *Handlers) recalcApp(appID int64) error {
// noStore keeps a page that shows a secret out of every cache and out of the
// browser's history copy: the password on it is shown once.
// RFC 9111 § 5.2.2.5: no-store keeps it out of every HTTP cache, private and
// shared. The history part is not what it governs (§ 6), and Pragma has no
// defined meaning in a response (§ 5.4): assumptions-2026-10-14, D-B13.
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
@@ -102,6 +102,8 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
}
}
// An unticked checkbox is not sent at all: absent is off.
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
// a checkbox whose checkedness is false is skipped.
notifications := r.PostFormValue("notify") != ""
if err := h.store.SetSendLogRetentionDays(days); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
+43 -8
View File
@@ -19,8 +19,17 @@ import (
// relative URL on the page, including the form actions;
// - form-action 'self' keeps a form from being retargeted at another host.
//
// CSP Level 3, https://www.w3.org/TR/CSP3/ § 6.8.3 "Get fetch directive
// fallback list": script, style, img, font, connect and object fall back to
// default-src; base-uri (§ 6.3.1), form-action (§ 6.4.1) and frame-ancestors
// (§ 6.4.2) are not in any list, so each is set here.
//
// This is a second line of defence: XSS is already prevented by
// html/template's contextual auto-escaping (security.md).
// html/template's contextual auto-escaping (security.md). Go 1.26,
// html/template doc.go "Security Model": template authors are trusted, the
// data is not. An attribute it does not know (hx-get, hx-swap, popovertarget)
// is escaped as plain text (attr.go attrType()), so the hx-get paths are built
// by the server, never taken from input.
const contentSecurityPolicy = "default-src 'self'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
@@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " +
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
// on the plain-HTTP development setup it would be meaningless at best and
// would pin the browser to a scheme the dev instance does not speak at worst.
// RFC 6797 § 8.1: a header received over insecure transport is ignored.
//
// Deliberately without includeSubDomains. The panel commonly lives on a
// Deliberately without includeSubDomains (RFC 6797 § 6.1.2: only with it does
// the policy reach the host's subdomains). The panel commonly lives on a
// subdomain of a domain used for other things, but it may also sit at the
// apex — and there the directive would force HTTPS on every unrelated
// subdomain of that domain for a year, which is not SelfPost's call to make.
@@ -51,6 +62,8 @@ func (s *Server) secure(next http.Handler) http.Handler {
h.Set("X-Content-Type-Options", "nosniff")
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
// that predate it. The two never disagree: both mean "never framed".
// CSP3 § 6.4.2.2: 'none' is DENY, and where frame-ancestors is
// enforced X-Frame-Options is ignored.
h.Set("X-Frame-Options", "DENY")
// The panel's behaviour never depends on the referrer, so the strictest
// value is free: panel URLs carry domain names and record identifiers
@@ -65,7 +78,12 @@ func (s *Server) secure(next http.Handler) http.Handler {
// being the name the browser actually used, which every reverse
// proxy fragment this project ships preserves (Apache
// ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy
// and Traefik by default) — but a proxy that rewrites Host turns
// and Traefik by default: httpd.apache.org/docs/2.4/mod/mod_proxy.html
// #proxypreservehost; nginx.org ngx_http_proxy_module
// #proxy_set_header, whose default is $proxy_host; Caddy 2
// reverse_proxy § Headers > Defaults, "passes through incoming
// headers - including Host"; Traefik service load balancer,
// passHostHeader true by default) — but a proxy that rewrites Host turns
// every form submission into a 403, and without these two values
// side by side the symptom just looks like "the panel stopped
// saving anything".
@@ -87,6 +105,9 @@ func (s *Server) secure(next http.Handler) http.Handler {
// domain, a forgotten staging host — counts as same-site and its forged POST
// would arrive with the session cookie attached. Sec-Fetch-Site and Origin are
// judged per *origin*, so they tell that neighbour apart from the panel.
// draft-ietf-httpbis-rfc6265bis § 5.2 (same-site is the [SAMESITE] "same
// site" of the two origins); W3C Fetch Metadata § 2.3 (Sec-Fetch-Site:
// same-origin only if every URL of the request is same origin).
//
// Read-only methods are exempt: every GET route in the panel is a read (the
// delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so
@@ -100,26 +121,40 @@ func originAllowed(r *http.Request) bool {
// The browser's own account of where the request came from. It cannot be
// set by page script (a forbidden header name), so when it is present it
// is the answer: anything but same-origin — including "none", a
// navigation with no initiator — is refused.
// navigation with no initiator — is refused. Fetch Standard § 2.2.2: a
// header name starting with `Sec-` is a forbidden request-header; Fetch
// Metadata § 2.3: "none" is a navigation the user started directly.
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin"
}
// Older browsers send only Origin. Compare hosts, not full origins: the
// panel speaks plain HTTP behind the proxy and does not know its own
// external scheme, while Origin will say https.
// Older browsers send only Origin, and so does a current one over plain
// HTTP at a name other than localhost: Fetch Metadata § 3 appends its
// headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a
// request that is neither GET nor HEAD carries Origin. (Which value it
// carries under this panel's Referrer-Policy: assumptions-2026-10-14, D-B3.)
// Compare hosts, not full origins: the panel speaks plain HTTP behind the
// proxy and does not know its own external scheme, while Origin will say
// https.
origin := r.Header.Get("Origin")
if origin == "" {
// Neither header. A client this old cannot be checked at all; it is
// let through as the risk consciously accepted (single-admin panel,
// the administrator picks the browser). Turning this return into
// false is the whole of the stricter policy.
// false is the whole of the stricter policy. By Fetch § 3.2 a browser
// that follows it never sends a POST without Origin; Firefox did for a
// same-origin form before 70 (MDN browser-compat-data 6.0.0,
// http.headers.Origin).
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
// Unparseable, or the opaque "null" origin (sandboxed iframe,
// redirected cross-origin POST) — not this panel either way.
// Fetch Standard § 2.2.5 "serializing a request origin": "null" for a
// redirect-tainted request; HTML § 7.1.1: an opaque origin serialises
// to "null"; url.Parse("null") has no Host. (Fetch § 3.2 also sends
// "null" under Referrer-Policy no-referrer: D-B3 of the same register.)
return false
}
return u.Host == r.Host
+13 -1
View File
@@ -110,6 +110,12 @@ type Tag struct {
// why a message was decided the way it was). It is the browser's own popover:
// the tag is a button that names the sheet by ID, so ID is unique on the page.
// Its content is a table of name and value.
//
// HTML Living Standard, popover.html: popovertarget on a button shows or hides
// (popovertargetaction="hide") the element with that id, with no script; a
// browser without the popover attribute (before Chrome 114, Firefox 125,
// Safari 17 - MDN browser-compat-data 6.0.0, html.global_attributes.popover)
// does not open it.
type Sheet struct {
ID string
Tag Tag
@@ -163,7 +169,9 @@ func StatusTag(status string) string {
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
// an address in a table cell breaks where it reads naturally, so the column can
// shrink without splitting a name mid-word.
// shrink without splitting a name mid-word. Go 1.26, text/template funcs.go
// HTMLEscape(): HTMLEscapeString escapes < > & ' " and NUL, so the only markup
// in the result is the <wbr> added here.
func wbrAt(s string) template.HTML {
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
}
@@ -283,6 +291,10 @@ type Box struct {
Poll string
// OOB marks the box to be swapped into the page by its ID when it comes
// in a polled fragment beside the element that asked for the fragment.
// htmx 2.0.4, src/htmx.js swap(): the response replaces only the target;
// findAndSwapOobElements() takes every hx-swap-oob element out of it,
// nested ones too (allowNestedOobSwaps defaults true), and oobSwap()
// with "true" swaps it outerHTML over the element of its id.
OOB bool
// DomainPick: see Box.
DomainPick bool
+6 -1
View File
@@ -16,7 +16,10 @@ import (
// a response carries no validator at all and the browser is free to guess how
// long to keep it — which is how a replaced favicon keeps showing the old mark
// long after a deploy. Hashing the content gives every asset a validator that
// changes exactly when the asset does.
// changes exactly when the asset does. RFC 9111 § 4.2.2: without explicit
// freshness a cache may assign a heuristic lifetime; Go 1.26, net/http fs.go
// ServeContent(): no Last-Modified for a zero modtime, and an ETag the caller
// set answers If-None-Match.
var staticETags = buildStaticETags()
func buildStaticETags() map[string]string {
@@ -50,6 +53,8 @@ func buildStaticETags() map[string]string {
// handful of small files on a single-operator panel that trade is the right way
// round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here.
// RFC 9111 § 5.2.2.4: no-cache lets a cache store the response but not reuse
// it without validating it first.
func StaticHandler() http.Handler {
files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Some files were not shown because too many files have changed in this diff Show More