Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a658da11f7 | ||
|
|
599149bf83 | ||
|
|
ad3d834392 | ||
|
|
b113e72c71 | ||
|
|
ce363f48dd |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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=$?
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -17,6 +17,8 @@ server {
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
|
||||
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
|
||||
http2 on;
|
||||
server_name mail.example.com;
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
@@ -25,7 +27,10 @@ services:
|
||||
# Traefik only proxies the panel; it never sees the mail ports.
|
||||
# These live on the selfpost service (not traefik's) because the
|
||||
# docker provider reads routing labels off the container being routed
|
||||
# to, not off traefik itself.
|
||||
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
|
||||
# docker/, "Routing Configuration": "Traefik uses container labels";
|
||||
# with exposedByDefault=false only containers labelled
|
||||
# traefik.enable=true.)
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+1695
-1
File diff suppressed because it is too large
Load Diff
+107
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]):])
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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} },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (?, ?)
|
||||
|
||||
@@ -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 < ?`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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") != "")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user