Compare commits
5
Commits
a8b6276b0d
...
a658da11f7
| 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
|
# Preseed Postfix so its install is non-interactive and yields a working
|
||||||
# main.cf. The real relay configuration is generated by the panel.
|
# 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 \
|
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||||
&& apt-get update \
|
&& 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
|
# 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
|
# 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).
|
# 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 \
|
RUN groupadd --system --gid 998 selfpost \
|
||||||
&& usermod -aG selfpost panel \
|
&& usermod -aG selfpost panel \
|
||||||
&& usermod -aG selfpost opendkim \
|
&& 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
|
# 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
|
# the panel bypass any Postfix restriction that a local user does not already
|
||||||
# have through the world-executable sendmail.
|
# 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
|
RUN usermod -aG postdrop panel
|
||||||
|
|
||||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
# 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
|
Communication with supervisord uses the event listener protocol over stdin and
|
||||||
stdout, so stdout must carry only protocol tokens.
|
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 os
|
||||||
import signal
|
import signal
|
||||||
|
|||||||
+14
-1
@@ -62,6 +62,8 @@ esac
|
|||||||
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
# 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
|
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||||
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
# 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
|
chown panel:panel /data
|
||||||
chmod 755 /data
|
chmod 755 /data
|
||||||
# Restored backups or previously-created state may contain panel-owned files
|
# 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
|
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||||
# just restored from a backup:
|
# just restored from a backup:
|
||||||
# - group `selfpost` + setgid on directories so anything the panel creates
|
# - 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);
|
# - private keys and tables group-readable (0640);
|
||||||
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||||
# daemon comes up cleanly with no domains yet.
|
# 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
|
# directory keeps the shared group on anything created inside it later, and
|
||||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||||
# rotate a log whose directory is writable by a non-root group.
|
# 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
|
mkdir -p /data/log
|
||||||
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||||
chown -R postfix:selfpost /data/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
|
# 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
|
# downtime (guide § Health) - a mark left from before would count the whole
|
||||||
# outage when supervisord cannot say since when Postfix runs.
|
# outage when supervisord cannot say since when Postfix runs.
|
||||||
|
# (test(1), -S: true for any socket file, whether or not anything listens.)
|
||||||
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
|
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
|
||||||
|
|
||||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
# 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.
|
# 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
|
# 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.
|
# 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
|
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
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
|
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
# 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
|
# 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
|
# (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.
|
# 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
|
set -eu
|
||||||
|
|
||||||
CONFIG=/etc/logrotate.d/mail
|
CONFIG=/etc/logrotate.d/mail
|
||||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||||
|
|
||||||
# logrotate refuses configs writable by group or others and exits 0 while
|
# 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() {
|
logrotate_config_ok() {
|
||||||
mode=$(stat -c '%a' "$CONFIG")
|
mode=$(stat -c '%a' "$CONFIG")
|
||||||
mode=${mode#0}
|
mode=${mode#0}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
# 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.
|
# 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 {
|
/data/log/mail.log {
|
||||||
daily
|
daily
|
||||||
rotate 14
|
rotate 14
|
||||||
|
|||||||
+18
-3
@@ -3,11 +3,16 @@
|
|||||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
# 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
|
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
# 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,
|
# 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
|
# 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
|
Syslog no
|
||||||
UMask 007
|
UMask 007
|
||||||
@@ -15,15 +20,25 @@ Mode s
|
|||||||
|
|
||||||
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
# 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.
|
# 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
|
KeyTable /data/opendkim/KeyTable
|
||||||
SigningTable refile:/data/opendkim/SigningTable
|
SigningTable refile:/data/opendkim/SigningTable
|
||||||
|
|
||||||
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
# 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
|
# `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
|
# 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
|
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
|
Socket local:/run/opendkim/opendkim.sock
|
||||||
PidFile /run/opendkim/opendkim.pid
|
PidFile /run/opendkim/opendkim.pid
|
||||||
UserID opendkim
|
UserID opendkim
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
# copies - not the mounted files, which a renewer may be rewriting - are a
|
# 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
|
# certificate and the key that belongs to it, renames both over the targets
|
||||||
# and reloads Postfix. When nothing changed it does nothing.
|
# 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
|
# 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
|
# 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
|
# 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
|
# passed here is written with commas only; a value with whitespace is a bug in
|
||||||
# this script and stops the container.
|
# 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() {
|
master_cf_set_smtp_inet_o() {
|
||||||
param="$1"
|
param="$1"
|
||||||
value="$2"
|
value="$2"
|
||||||
@@ -62,11 +75,19 @@ master_cf_set_smtp_inet_o() {
|
|||||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||||
# fails. Fall back to the container hostname only outside a real deployment.
|
# 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)}"
|
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
|
||||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
# 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
|
# 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
|
# 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
|
# 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}"
|
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||||
|
|
||||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
# 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}"
|
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||||
# Transit mail queue under /data so deferred/active messages survive container
|
# Transit mail queue under /data so deferred/active messages survive container
|
||||||
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
# 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 §
|
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
# /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 \
|
postconf -e \
|
||||||
"myhostname=${HOSTNAME_VALUE}" \
|
"myhostname=${HOSTNAME_VALUE}" \
|
||||||
"maillog_file=${MAIL_LOG_PATH}" \
|
"maillog_file=${MAIL_LOG_PATH}" \
|
||||||
@@ -160,7 +191,9 @@ postconf -e \
|
|||||||
"inet_interfaces=all" \
|
"inet_interfaces=all" \
|
||||||
"inet_protocols=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
|
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||||
# image cannot lose the service.
|
# image cannot lose the service.
|
||||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||||
@@ -178,7 +211,10 @@ postconf -e \
|
|||||||
"smtp_tls_note_starttls_offer=yes"
|
"smtp_tls_note_starttls_offer=yes"
|
||||||
|
|
||||||
# TLS server material shared by every inbound service. auth_only guarantees
|
# 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 \
|
postconf -e \
|
||||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
"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;
|
# 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 sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||||
# the server realm, which equals the realm the accounts were created under.
|
# 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 \
|
postconf -e \
|
||||||
"smtpd_sasl_auth_enable=yes" \
|
"smtpd_sasl_auth_enable=yes" \
|
||||||
"smtpd_sasl_type=cyrus" \
|
"smtpd_sasl_type=cyrus" \
|
||||||
@@ -200,14 +238,19 @@ postconf -e \
|
|||||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||||
"broken_sasl_auth_clients=yes"
|
"broken_sasl_auth_clients=yes"
|
||||||
|
|
||||||
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
|
||||||
# full address first, then the "@domain" wildcard, so both address modes work
|
# up in this map with mail_addr_find(), the order being smtpd's and not
|
||||||
# from the same map.
|
# 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:
|
# 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@,
|
# 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
|
# 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.
|
# (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 \
|
postconf -e \
|
||||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||||
"recipient_delimiter=+" \
|
"recipient_delimiter=+" \
|
||||||
@@ -216,7 +259,9 @@ postconf -e \
|
|||||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
# and every authenticated sender address must be owned by its login. NO
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
# 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 \
|
postconf -e \
|
||||||
"smtpd_helo_required=yes" \
|
"smtpd_helo_required=yes" \
|
||||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
@@ -224,7 +269,11 @@ postconf -e \
|
|||||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||||
|
|
||||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
# 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 \
|
postconf -e \
|
||||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||||
@@ -244,6 +293,13 @@ postconf -e \
|
|||||||
# signs them with the key of the server's own name; the journal-milter is not
|
# 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
|
# 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).
|
# 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 \
|
postconf -e \
|
||||||
"milter_protocol=6" \
|
"milter_protocol=6" \
|
||||||
"milter_default_action=tempfail" \
|
"milter_default_action=tempfail" \
|
||||||
@@ -254,7 +310,8 @@ postconf -e \
|
|||||||
# address. The panel's milters judge every message by that address (an
|
# address. The panel's milters judge every message by that address (an
|
||||||
# application's client-address list) and record it (the inbound journal), and
|
# 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
|
# 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 \
|
postconf -e \
|
||||||
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
|
"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,
|
# 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
|
# but within seconds rather than minutes. Values are well above any healthy
|
||||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||||
# operation.
|
# 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 \
|
postconf -e \
|
||||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||||
"milter_command_timeout=${MILTER_COMMAND_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
|
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||||
# and the Cyrus config outside any chroot.
|
# 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 -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||||
@@ -283,7 +345,10 @@ postconf -P \
|
|||||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
# 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
|
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
@@ -295,7 +360,9 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
# --- 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
|
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||||
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
# 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
|
# postconf -P refuses them ("unmatched service_name/type") and leaves a
|
||||||
# bare "pipe", which dies with "fatal: missing user= command-line
|
# bare "pipe", which dies with "fatal: missing user= command-line
|
||||||
# attribute" on every report. They go into the service line itself.
|
# 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
|
# ${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
|
# 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
|
# 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.
|
# 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.
|
# command reads from the container's environment is named there.
|
||||||
# export_environment is a list split at whitespace in which Postfix
|
# export_environment is a list split at whitespace in which Postfix
|
||||||
# expands "$": a path with either would reach the command as another
|
# 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
|
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
|
||||||
*[[:space:]]* | *'$'*)
|
*[[:space:]]* | *'$'*)
|
||||||
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
|
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}"
|
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
fi
|
fi
|
||||||
# Comma, not a space, between check_recipient_access and its table: see
|
# 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}"
|
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||||
else
|
else
|
||||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
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
|
# 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 as well; without it they are "User unknown in relay recipient
|
||||||
# table" and no report is ever accepted while both features are on.
|
# 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}"
|
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||||
fi
|
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 \
|
postconf -e \
|
||||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
"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 — polluting Deliveries and letting a forged From: consume that
|
||||||
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
# 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
|
# 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=""
|
INBOUND_MILTERS=""
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||||
# A glob's "*" matches anything, so the shape alone would pass
|
# 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
|
# 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
|
# it is down. A comma, not a space, between the two - see
|
||||||
# master_cf_set_smtp_inet_o.
|
# 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
|
case "${INBOUND_JOURNAL_SOCK}" in
|
||||||
*[!A-Za-z0-9._/-]*)
|
*[!A-Za-z0-9._/-]*)
|
||||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
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
|
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
|
||||||
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
|
# 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 -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
"smtp/inet/syslog_name=postfix/inbound" \
|
"smtp/inet/syslog_name=postfix/inbound" \
|
||||||
@@ -484,13 +580,23 @@ fi
|
|||||||
# Inside a container the chroot buys little (the container is the isolation
|
# 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
|
# 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
|
# 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"
|
postconf -F "*/*/chroot=n"
|
||||||
|
|
||||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||||
# only — both are safe because TLS is mandatory before auth on every port.
|
# 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
|
mkdir -p /etc/postfix/sasl
|
||||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||||
pwcheck_method: auxprop
|
pwcheck_method: auxprop
|
||||||
@@ -500,7 +606,9 @@ mech_list: PLAIN LOGIN
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Validate the generated configuration; fail loudly if postconf produced
|
# 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
|
set +e
|
||||||
check_out=$(postfix check 2>&1)
|
check_out=$(postfix check 2>&1)
|
||||||
ec=$?
|
ec=$?
|
||||||
|
|||||||
@@ -39,4 +39,8 @@ for sock in $SOCKS; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
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
|
exec postfix start-fg
|
||||||
|
|||||||
+18
-3
@@ -3,7 +3,10 @@
|
|||||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
; 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
|
; the sender check's and the journal-milter's sockets), then the Postfix
|
||||||
; wrapper — which additionally blocks until the milter sockets are ready
|
; 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
|
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||||
; event listener brings the whole container down so Docker's restart policy can
|
; 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
|
; 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
|
; 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
|
; 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]
|
[unix_http_server]
|
||||||
file=/run/supervisor.sock
|
file=/run/supervisor.sock
|
||||||
chown=root:selfpost
|
chown=root:selfpost
|
||||||
@@ -45,6 +50,10 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[program:panel]
|
||||||
command=/usr/local/bin/panel
|
command=/usr/local/bin/panel
|
||||||
user=panel
|
user=panel
|
||||||
@@ -76,7 +85,10 @@ stderr_logfile_maxbytes=0
|
|||||||
; supervisord — over the group-accessible control socket — to run this program,
|
; supervisord — over the group-accessible control socket — to run this program,
|
||||||
; which executes the canonical `postfix reload` as root. autostart is off (it
|
; 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
|
; 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]
|
[program:postfix-reload]
|
||||||
command=/usr/sbin/postfix reload
|
command=/usr/sbin/postfix reload
|
||||||
autostart=false
|
autostart=false
|
||||||
@@ -120,6 +132,9 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[eventlistener:crashexit]
|
||||||
command=/usr/local/bin/crashexit.py
|
command=/usr/local/bin/crashexit.py
|
||||||
events=PROCESS_STATE_FATAL
|
events=PROCESS_STATE_FATAL
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ func runDMARCIngestMode() error {
|
|||||||
}
|
}
|
||||||
// Not a report this relay keeps. It is dropped: deferring would have
|
// 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
|
// 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 {
|
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
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
|
// what a delivery command wrote - about a hundred bytes - into its own line
|
||||||
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
|
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
|
||||||
// so the reason goes out first and bare: no prefix, no date, no recipient,
|
// 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
|
// all of which that line already has. Postfix 3.7.11, src/pipe/pipe.c,
|
||||||
// the command without the group that may write to Postfix's log socket.
|
// 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) {
|
func logRejected(reason error) {
|
||||||
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.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
|
// 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,
|
// 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).
|
// 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
|
// Without it the check that a report came to its own domain's address is
|
||||||
// skipped.
|
// skipped.
|
||||||
func ingestRecipient(args []string) string {
|
func ingestRecipient(args []string) string {
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
ms := newMailStack(cfg, st)
|
ms := newMailStack(cfg, st)
|
||||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||||
// effective config, including a manual override; the panel keeps this
|
// 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()
|
retryPolicy := postfix.LoadRetryPolicy()
|
||||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ func listenMilter(socketPath string) (net.Listener, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
// 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) {
|
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, err
|
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
|
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
// 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).
|
// 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 {
|
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||||
ln.Close()
|
ln.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+14
-2
@@ -34,7 +34,12 @@ func main() {
|
|||||||
log.SetPrefix("dmarc-ingest: ")
|
log.SetPrefix("dmarc-ingest: ")
|
||||||
if err := runDMARCIngestMode(); err != nil {
|
if err := runDMARCIngestMode(); err != nil {
|
||||||
log.Printf("ingest failed: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,14 @@ func antispamAction(raw string) string {
|
|||||||
|
|
||||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
// 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
|
// 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 {
|
func saslRealm() string {
|
||||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||||
return 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
|
// 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
|
// 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 {
|
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
|
||||||
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
|
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
|
||||||
} else if rebuilt {
|
} else if rebuilt {
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import (
|
|||||||
const aliveEvery = 2 * time.Second
|
const aliveEvery = 2 * time.Second
|
||||||
|
|
||||||
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
|
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
|
||||||
// socket of its own. Postfix attaches that socket strictly, so while this
|
// socket of its own. Postfix attaches that socket strictly
|
||||||
// role is not serving, mail is deferred: what that costs is counted here and
|
// (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
|
// by the log tailer, and shown on Server > Health
|
||||||
// (docs/plans/from-header-binding.md § Statistics).
|
// (docs/plans/from-header-binding.md § Statistics).
|
||||||
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
|
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
|
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
|
||||||
|
|
||||||
// postfixStarted asks supervisord since when Postfix has been running, again
|
// 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 {
|
func postfixStarted() postfixState {
|
||||||
deadline := time.Now().Add(postfixAskFor)
|
deadline := time.Now().Add(postfixAskFor)
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ services:
|
|||||||
- antispam
|
- antispam
|
||||||
|
|
||||||
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
|
image: rspamd/rspamd:3.11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No host ports: only SelfPost talks to the milter on the compose network.
|
# 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
|
# 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
|
# signature travels on with the mail and is of no use to whoever sees it: it
|
||||||
# fits that one queue id.
|
# 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"];
|
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||||
|
|
||||||
custom {
|
custom {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
|
# 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
|
# 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.
|
# /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
|
# 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
|
# 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
|
# first decide - but given a weight no spam score outweighs, which a refusal
|
||||||
# by the deny list still overrides.
|
# 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
|
# 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.
|
# 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
|
# 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'`)
|
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||||
# before wiring it into the volume mount below.
|
# 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
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
# resolves every relative path in both files against the directory of the
|
# 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
|
# 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
|
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||||
# self-healing; without them chmod fails with EPERM and the container
|
# self-healing; without them chmod fails with EPERM and the container
|
||||||
# crash-loops on start.
|
# crash-loops on start.
|
||||||
|
# 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:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
# takes over 80/443 and proxies to the panel over the compose network
|
# 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
|
# 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
|
# (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
|
# 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
|
- ./nginx/certbot-www:/var/www/certbot
|
||||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||||
# until a certificate is within its renewal window.
|
# 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'
|
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
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;
|
http2 on;
|
||||||
server_name mail.example.com;
|
server_name mail.example.com;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
#
|
#
|
||||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
# resolves every relative path in both files against the directory of the
|
# 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
|
# 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.
|
# Traefik only proxies the panel; it never sees the mail ports.
|
||||||
# These live on the selfpost service (not traefik's) because the
|
# These live on the selfpost service (not traefik's) because the
|
||||||
# docker provider reads routing labels off the container being routed
|
# 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.enable=true
|
||||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
# by SelfPost/Postfix.
|
# by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# 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
|
set -eu
|
||||||
|
|
||||||
ACME_JSON="${1:?path to acme.json}"
|
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`)
|
shared files of lines (`from-lines-passed.txt`, `from-lines-other.txt`)
|
||||||
are kept: the second one's test becomes "never signed for {other}"
|
are kept: the second one's test becomes "never signed for {other}"
|
||||||
rather than "refused". - **Opus**
|
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: "нам нужно полное ревью
|
answered from its source** (owner, 2026-10-14: "нам нужно полное ревью
|
||||||
кода на предмет всей этой эвристики. Её не должно там, где можно
|
кода на предмет всей этой эвристики. Её не должно там, где можно
|
||||||
посмотреть код и ответить точно и однозначно" and "в коде таких наших
|
посмотреть код и ответить точно и однозначно" 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
|
window), the smtpd that outlives a reload (measured, not understood), the
|
||||||
NOQUEUE line's fields (two fixtures carry a sasl_username= Postfix does
|
NOQUEUE line's fields (two fixtures carry a sasl_username= Postfix does
|
||||||
not write, one presented as seen on the stand). - **Opus**
|
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
|
- [ ] Docs and wording: help topic sender-check and guide § Who an
|
||||||
application may send as (mockup first), security.md § Mail path,
|
application may send as (mockup first), security.md § Mail path,
|
||||||
CHANGELOG. - **Sonnet**
|
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) |
|
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
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
|
// 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
|
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||||
// Postfix's SASL configuration.
|
// 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 {
|
func NewSASLDB(path, realm string) *SASLDB {
|
||||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||||
}
|
}
|
||||||
@@ -46,6 +52,11 @@ func (s *SASLDB) Set(login, password string) error {
|
|||||||
if err := validateLogin(login); err != nil {
|
if err := validateLogin(login); err != nil {
|
||||||
return err
|
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).
|
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||||
// -c: create the account / set the password.
|
// -c: create the account / set the password.
|
||||||
// -f: operate on our sasldb2 rather than the system default path.
|
// -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
|
// 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.
|
// 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 {
|
func (s *SASLDB) Delete(login string) error {
|
||||||
if err := validateLogin(login); err != nil {
|
if err := validateLogin(login); err != nil {
|
||||||
return err
|
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
|
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||||
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||||
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
// "<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) {
|
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||||
sc := bufio.NewScanner(bytes.NewReader(dump))
|
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||||
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
// 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
|
// makeDump builds a db_dump byte-value document from key/value byte pairs. It
|
||||||
// same shape `db_dump <sasldb2>` emits.
|
// 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 {
|
func makeDump(pairs [][2][]byte) []byte {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
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
|
// applications to the panel. The store refuses such a pair as well
|
||||||
// (idx_applications_login_nocase); a login imported from another instance is
|
// (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.
|
// 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 {
|
func foldLogin(login string) string {
|
||||||
return strings.ToLower(login)
|
return strings.ToLower(login)
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,9 @@ func foldLogin(login string) string {
|
|||||||
// validateLogin enforces a strict server-side whitelist for the SASL login
|
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||||
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
||||||
// where '@' separates the user from the realm, so allowing it would change the
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// still waiting for delivery; a start without a queue directory builds an
|
||||||
// empty one. Whoever needs the queue stops the instance and archives the
|
// empty one. Whoever needs the queue stops the instance and archives the
|
||||||
// project directory (guide § Taking a full backup).
|
// 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
|
// 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
|
// 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
|
// 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) {
|
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||||
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
||||||
if err != nil {
|
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-wal"), "wal")
|
||||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
|
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
|
||||||
// Postfix's delivery log and its rotated files: diagnostic output, not
|
// 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"), "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(dataDir, "log", "mail.log.1"), "older")
|
||||||
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
|
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)
|
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 {
|
func looksLikeAggregate(name string) bool {
|
||||||
name = strings.ToLower(name)
|
name = strings.ToLower(name)
|
||||||
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||||
|
|||||||
+10
-1
@@ -40,6 +40,11 @@ type ParsedRecord struct {
|
|||||||
HeaderFrom string
|
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 {
|
type feedbackXML struct {
|
||||||
XMLName xml.Name `xml:"feedback"`
|
XMLName xml.Name `xml:"feedback"`
|
||||||
Metadata struct {
|
Metadata struct {
|
||||||
@@ -160,6 +165,9 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
|
|||||||
return out, nil
|
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 {
|
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||||
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
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
|
// decompress unwraps the container the reporter used. Both are seen in the
|
||||||
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
|
// 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) {
|
func decompress(raw []byte) ([]byte, error) {
|
||||||
switch {
|
switch {
|
||||||
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
|
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"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"?>
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
<feedback>
|
<feedback>
|
||||||
<report_metadata>
|
<report_metadata>
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
|
|||||||
// and entrypoint.sh); the parent directory carries setgid so the group is
|
// and entrypoint.sh); the parent directory carries setgid so the group is
|
||||||
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
||||||
// a half-written key.
|
// a half-written key.
|
||||||
|
//
|
||||||
|
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
|
||||||
|
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
|
||||||
|
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
|
||||||
|
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
|
||||||
|
// (build/opendkim.conf).
|
||||||
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
||||||
block := &pem.Block{
|
block := &pem.Block{
|
||||||
Type: "RSA PRIVATE KEY",
|
Type: "RSA PRIVATE KEY",
|
||||||
|
|||||||
@@ -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
|
// 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
|
// record of one key; tables that name another would have OpenDKIM sign with a
|
||||||
// key whose record nobody is told to publish.
|
// 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) {
|
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
|
||||||
has := func(path, prefix string) (bool, error) {
|
has := func(path, prefix string) (bool, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -222,6 +226,13 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
|||||||
sorted := append([]SigningDomain(nil), domains...)
|
sorted := append([]SigningDomain(nil), domains...)
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
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
|
var kt, st strings.Builder
|
||||||
for _, d := range sorted {
|
for _, d := range sorted {
|
||||||
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
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
|
// 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
|
// 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:/\\"
|
const opendkimForbidden = " \t\r\n:/\\"
|
||||||
|
|
||||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
// 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,
|
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||||
// and it never goes through a shell (security.md).
|
// 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 {
|
func reloadViaSupervisor() error {
|
||||||
return supervisor.Signal("USR1", "opendkim")
|
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
|
// 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.
|
// 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
|
const certWarnDays = 14
|
||||||
|
|
||||||
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
||||||
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
||||||
// the file is supplied by the reverse proxy through a read-only mount.
|
// 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 {
|
type Certificate struct {
|
||||||
Path string
|
Path string
|
||||||
Subject string
|
Subject string
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ func TestWorstPicksMostSevere(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseProcesses(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
|
out := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||||
panel RUNNING pid 22, uptime 0:04:09
|
panel RUNNING pid 22, uptime 0:04:09
|
||||||
postfix FATAL Exited too quickly (process log may have details)
|
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) {
|
func TestLivenessFromParsedProcesses(t *testing.T) {
|
||||||
|
// Composed, in the columns of TestParseProcesses.
|
||||||
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
|
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||||
panel RUNNING pid 22, uptime 0:04:09
|
panel RUNNING pid 22, uptime 0:04:09
|
||||||
postfix RUNNING pid 23, uptime 0:04:08
|
postfix RUNNING pid 23, uptime 0:04:08
|
||||||
@@ -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) {
|
func TestUptime(t *testing.T) {
|
||||||
for detail, want := range map[string]time.Duration{
|
for detail, want := range map[string]time.Duration{
|
||||||
"pid 41, uptime 0:03:12": 3*time.Minute + 12*time.Second,
|
"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
|
// status page's answer to "is the server itself under strain", next to the
|
||||||
// component checks that answer "is mail flowing".
|
// component checks that answer "is mail flowing".
|
||||||
//
|
//
|
||||||
// The numbers come from the kernel's /proc filesystem, which a container shares
|
// The numbers come from the kernel's /proc filesystem. /proc/stat and
|
||||||
// with its host unless it was started with its own namespaces — so CPU and
|
// /proc/meminfo are system-wide and no namespace changes them (proc(5):
|
||||||
// memory describe the host, while /proc/net/dev describes whatever network
|
// "kernel/system statistics", "statistics about memory usage on the
|
||||||
// namespace the container is in (its own veth under the default bridge, the
|
// system"), so CPU and memory describe the host, while /proc/net/dev
|
||||||
// host's interfaces under network_mode: host).
|
// 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 {
|
type Machine struct {
|
||||||
CPU CPU
|
CPU CPU
|
||||||
Memory Memory
|
Memory Memory
|
||||||
@@ -276,7 +279,9 @@ func (c CPU) contextText() string {
|
|||||||
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
|
// 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
|
// 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
|
// 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 {
|
func readMemory(root string) Memory {
|
||||||
var m Memory
|
var m Memory
|
||||||
fields, err := readMeminfo(root)
|
fields, err := readMeminfo(root)
|
||||||
@@ -398,11 +403,15 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if fields[0] != "cpu" {
|
if fields[0] != "cpu" {
|
||||||
cores++ // cpu0, cpu1, … — one line per core
|
cores++ // cpu0, cpu1, … — one line per online logical CPU
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// user nice system idle iowait irq softirq steal (guest fields are
|
// 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:] {
|
for i, f := range fields[1:] {
|
||||||
if i >= 8 {
|
if i >= 8 {
|
||||||
break
|
break
|
||||||
@@ -428,7 +437,11 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
|
|||||||
|
|
||||||
// readCPUTopology returns physical core and logical thread counts from
|
// readCPUTopology returns physical core and logical thread counts from
|
||||||
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
|
// /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) {
|
func readCPUTopology(root string) (cores, threads int) {
|
||||||
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
|
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -522,7 +535,9 @@ func isZeroLoad(load [3]float64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
|
// 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) {
|
func readMeminfo(root string) (map[string]uint64, error) {
|
||||||
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||||||
if err != nil {
|
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
|
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
|
||||||
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
|
// 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
|
// 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) {
|
func readNetDev(root string) (map[string]netCounters, error) {
|
||||||
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
|
|
||||||
// fakeProc writes a /proc-shaped directory the sampler can be pointed at, so
|
// 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
|
// 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 {
|
func fakeProc(t *testing.T, stat, meminfo, loadavg, netdev string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
// add to a poll.
|
// add to a poll.
|
||||||
const milterDialTimeout = time.Second
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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) {
|
func Processes() ([]Process, error) {
|
||||||
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
|
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
@@ -54,7 +56,8 @@ func Processes() ([]Process, error) {
|
|||||||
|
|
||||||
// supervisorStates are the state words supervisord prints. Lines whose second
|
// 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
|
// 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{
|
var supervisorStates = map[string]bool{
|
||||||
"STOPPED": true,
|
"STOPPED": true,
|
||||||
"STARTING": true,
|
"STARTING": true,
|
||||||
@@ -68,6 +71,10 @@ var supervisorStates = map[string]bool{
|
|||||||
|
|
||||||
// parseProcesses turns supervisorctl's tabular output into Process values. Each
|
// parseProcesses turns supervisorctl's tabular output into Process values. Each
|
||||||
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
|
// 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 {
|
func parseProcesses(out string) []Process {
|
||||||
var procs []Process
|
var procs []Process
|
||||||
for _, line := range strings.Split(out, "\n") {
|
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
|
// 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,
|
// 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) {
|
func Uptime(detail string) (time.Duration, bool) {
|
||||||
_, rest, found := strings.Cut(detail, "uptime ")
|
_, rest, found := strings.Cut(detail, "uptime ")
|
||||||
if !found {
|
if !found {
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ type Socket struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckSocket stats a milter socket. required distinguishes the two milters:
|
// CheckSocket stats a milter socket. required distinguishes the two milters:
|
||||||
// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
|
// Postfix attaches OpenDKIM with default_action=tempfail, so a missing socket
|
||||||
// leaving the server, while the journal-milter fails open — mail still goes out,
|
// stops mail leaving the server, while the journal-milter is attached with
|
||||||
// only the send log stops being written.
|
// 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 {
|
func CheckSocket(name, path string, required bool) Socket {
|
||||||
s := Socket{Name: name, Path: path}
|
s := Socket{Name: name, Path: path}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ type ListStore interface {
|
|||||||
// line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads
|
// 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
|
// a file when it changes. Which list wins when both match is the filter's
|
||||||
// configuration: deny.
|
// 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 {
|
type Lists struct {
|
||||||
store ListStore
|
store ListStore
|
||||||
dir string
|
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
|
// 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
|
// 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 {
|
if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -165,6 +174,8 @@ func (l *Lists) sync() error {
|
|||||||
}
|
}
|
||||||
// The filter runs under another user in another container: the directory
|
// The filter runs under another user in another container: the directory
|
||||||
// and the files are readable by anyone. They hold addresses, not secrets.
|
// 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 {
|
if err := os.MkdirAll(l.dir, 0o755); err != nil {
|
||||||
return fmt.Errorf("create %s: %w", l.dir, err)
|
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
|
// 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,
|
// 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.
|
// 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 (
|
const (
|
||||||
denyReplyMark = "this server's deny list"
|
denyReplyMark = "this server's deny list"
|
||||||
allowMark = "SELFPOST_ALLOW_"
|
allowMark = "SELFPOST_ALLOW_"
|
||||||
|
|||||||
@@ -314,6 +314,9 @@ func TestListsUsage(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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.
|
// 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)
|
row("Spammer@Evil.Example", "192.0.2.9", store.SpamDecisionReject, store.SpamEngineRspamd, denied)
|
||||||
// hour 2: another address of the domain.
|
// 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
|
// 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
|
// file does not share (rename(2): the file is moved, other hard links to it
|
||||||
// whose first bytes happen to match are still told apart. The device number
|
// 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
|
// 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
|
// file systems), and the same log would then pass for a rotated one (review
|
||||||
// of stage 2b).
|
// 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
|
// so it counts only for a queue id the port-25 smtpd was seen to open
|
||||||
// (inboundClientRe).
|
// (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
|
// 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,
|
// 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
|
// 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=`)
|
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
|
// 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
|
// 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=)`)
|
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
|
// 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.
|
// 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
|
// Refusals are written at the rate strangers send RCPT TO. Postfix's one
|
||||||
// Postfix's counts, so between two prunes the journal would otherwise grow as
|
// limit on that, smtpd_client_recipient_rate_limit, is per client address and
|
||||||
// fast as someone cared to make it.
|
// 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
|
const inboundPruneEvery = 500
|
||||||
|
|
||||||
// inboundJournal is the per-run state of the inbound half.
|
// 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]
|
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
|
// 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
|
// 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
|
// the recipient (Postfix 3.7.11 logs addresses in external form, the local
|
||||||
// quote. An address with a quote, a bracket or a space in it is not one the
|
// part quoted when it holds spaces or specials: postconf(5)
|
||||||
// journal keeps.
|
// 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, "\"<> ") {
|
if strings.ContainsAny(from, "\"<> ") || strings.ContainsAny(to, "\"<> ") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -178,7 +198,12 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
|
|||||||
e.Engine = store.SpamEnginePostfix
|
e.Engine = store.SpamEnginePostfix
|
||||||
}
|
}
|
||||||
// "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply
|
// "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") {
|
if e.Decision == store.SpamDecisionReject && strings.HasPrefix(reason, "4") {
|
||||||
e.Decision = store.SpamDecisionTempfail
|
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
|
// 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
|
// 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
|
// (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
|
// container's. Postfix 3.7.11, src/util/msg_logger.c, msg_logger_print(): the
|
||||||
// stamp is dated now.
|
// 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) {
|
func lineTime(line string, now time.Time) (time.Time, string) {
|
||||||
if m := isoStampRe.FindStringSubmatch(line); m != nil {
|
if m := isoStampRe.FindStringSubmatch(line); m != nil {
|
||||||
rest := strings.TrimSpace(line[len(m[0]):])
|
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)
|
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 (
|
const (
|
||||||
head = "2026-10-11T12:00:05.123456+00:00 mail "
|
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>"
|
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
|
// "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
|
// enable_long_queue_ids, digits and letters without vowels; neither word is
|
||||||
// either.
|
// 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}): (.*)`)
|
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
|
// smtpdProgramRe tells an smtpd from the other programs, whatever listener it
|
||||||
// serves: "postfix/smtpd", "postfix/submission/smtpd", "postfix/inbound/smtpd".
|
// 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$`)
|
var smtpdProgramRe = regexp.MustCompile(`^postfix/(?:[a-z0-9-]+/)?smtpd$`)
|
||||||
|
|
||||||
// port25Program is the smtpd of the inbound relay's listener, which
|
// 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
|
// 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
|
// 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
|
// 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
|
lineGrace = time.Hour
|
||||||
|
|
||||||
// lineBatch is how many lines are held before they are written without
|
// 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
|
// 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
|
// and prune later. An id leaves when qmgr removes the message or when
|
||||||
// another listener opens a message under it (Postfix reuses ids).
|
// 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{}
|
port25 map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"time"
|
"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
|
// 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.
|
// at the head of the line and nothing the far end wrote further along it.
|
||||||
func TestQueueLineHead(t *testing.T) {
|
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.
|
// 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
|
var queueIDs = postfix.QueueIDs
|
||||||
|
|
||||||
// retryPolicy reads Postfix's queue lifetimes, for counting the rows left
|
// 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)
|
// 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
|
// The "<queue-id>: to=<addr>, …, status=<word>" shape is the per-recipient
|
||||||
// delivery agents; qmgr/smtpd/cleanup lines do not match.
|
// 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=,
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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+)`)
|
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.
|
// 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
|
// 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()
|
policy := retryPolicy()
|
||||||
overLifetime := policy.LongestLifetime() + policy.MaximalBackoff
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
func deliveryInLog(path string, row store.QueuedDelivery) bool {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return false
|
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
|
// 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
|
// 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
|
// found inside a longer one — queue ids are hexadecimal, and one being the tail
|
||||||
// of another is ordinary, not unlikely.
|
// 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'
|
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
|
// Timestamps at the head of a mail.log line (maillog_file in
|
||||||
// writes (maillog_file in build/postfix-config.sh).
|
// 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
|
// syslogStampRe is the one that matches. Postfix 3.7.11,
|
||||||
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
|
// src/util/msg_logger.c, msg_logger_print(): every record is stamped
|
||||||
// image is built on Debian's 3.7 — where the parameter does not exist and the
|
// strftime("%b %d %H:%M:%S ") in the logging process's local time (the day
|
||||||
// only format is syslog's traditional one. It carries no year and no zone, so
|
// zero-padded), then the first label of the host name, and postlogd writes it
|
||||||
// the stamp shown is a wall clock and nothing more, which is all this column
|
// as received (src/postlogd/postlogd.c, postlogd_service()). It carries no
|
||||||
// claims to be.
|
// 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
|
// isoStampRe was written for an RFC 3339 format said to come with a
|
||||||
// base image carries a Postfix new enough to offer it. Matching it first costs
|
// maillog_file_format parameter of Postfix 3.9. No such parameter exists in
|
||||||
// one failed anchor per line and means the upgrade needs no change here.
|
// 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 (
|
var (
|
||||||
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
|
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`)
|
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
|
// 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
|
// 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'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
|
// 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
|
// 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
|
// gained lines between the drain() above and this check, since
|
||||||
// Postfix keeps writing to it until it reloads. Drain it once
|
// Postfix keeps writing to it until it reloads. Drain it once
|
||||||
// more before switching so nothing in that gap is lost.
|
// 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()
|
drain()
|
||||||
if err := openAt(0, io.SeekStart); err != nil {
|
if err := openAt(0, io.SeekStart); err != nil {
|
||||||
log.Printf("log-tailer: reopen %s: %v", path, err)
|
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
|
// 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
|
// 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
|
// 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 {
|
type blindness struct {
|
||||||
add func(from time.Time, d time.Duration) error
|
add func(from time.Time, d time.Duration) error
|
||||||
since time.Time // start of the current stretch; zero when reading
|
since time.Time // start of the current stretch; zero when reading
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"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) {
|
func TestParseDelivery(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -787,6 +794,8 @@ func TestSplitTimestamp(t *testing.T) {
|
|||||||
// Copied off the live relay (Postfix 3.7, which has no
|
// Copied off the live relay (Postfix 3.7, which has no
|
||||||
// maillog_file_format), so this is the shape the panel actually
|
// maillog_file_format), so this is the shape the panel actually
|
||||||
// meets: zero-padded day, and the host is the container's name.
|
// 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",
|
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",
|
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",
|
stamp: "Aug 08 07:26:41",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// fingerprintSize is how many bytes from the head of the log identify it.
|
// 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
|
// effectively unique per log generation — enough to tell "the file we were
|
||||||
// reading" from "a fresh one created by logrotate while we were down",
|
// reading" from "a fresh one created by logrotate while we were down",
|
||||||
// which os.SameFile cannot answer across a restart.
|
// 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
|
// 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
|
// 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 -
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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,
|
// 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
|
// stage 2b). The refusal has no queue id; it is matched to its row by client
|
||||||
// matched to its row by client address, sender and time. As the stand writes
|
// address, sender and time. As the stand writes it (e2e stand, date not
|
||||||
// it:
|
// 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/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.
|
// 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=<([^>]*)>`)
|
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
|
// 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>
|
// 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
|
// 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
|
// 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
|
// ";" 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=<([^>]*)>`)
|
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
|
// clientLoginRe is smtpd's line naming the login a queue id was submitted
|
||||||
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
|
// 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+)`)
|
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.
|
// 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
|
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
|
// 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
|
// row is not written yet waits and is tried again; port 25's and other
|
||||||
// lines are not refusals of an application.
|
// 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
|
// 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.
|
// 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 -
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// tells a hung panel from a stopped one - was never counted (audit
|
||||||
// 2026-10-12, M13).
|
// 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
|
// panel frozen in the middle of a message (cleanup 1339, 2026-10-14); the
|
||||||
// stamps are put in ISO form. Around them are the lines of two other things
|
// stamps are put in ISO form. Around them are the lines of two other things
|
||||||
// that use the same words - a level-2 refusal of the journal milter, OpenDKIM
|
// that use the same words - a level-2 refusal of the journal milter, 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) {
|
func TestDeferredCounter(t *testing.T) {
|
||||||
st := &deferredStore{}
|
st := &deferredStore{}
|
||||||
c := newDeferredCounter(&SenderCheck{Store: st, Socket: "/run/selfpost/sender.sock"})
|
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
|
// Like the send-log milter it only watches: every callback continues, and
|
||||||
// Postfix attaches the socket with default_action=accept, so
|
// Postfix attaches the socket with default_action=accept, so
|
||||||
// neither an error here nor the panel being down holds up inbound mail.
|
// 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 {
|
type inboundSession struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
rec InboundStore
|
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
|
// 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
|
// the last one read is its own - when it is up. Whether it was is decided at
|
||||||
// end-of-message, by the signature.
|
// 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) {
|
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||||
switch textproto.CanonicalMIMEHeaderKey(name) {
|
switch textproto.CanonicalMIMEHeaderKey(name) {
|
||||||
case "Subject":
|
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
|
// 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
|
// fits one queue id, so the header going on to the upstream with the mail
|
||||||
// gives nothing to whoever reads it there.
|
// 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 {
|
func FilterSignature(key, queueID string) string {
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
mac.Write([]byte(queueID))
|
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
|
// 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
|
// upstream. "Continue" lets it through as "accept" would, and keeps this
|
||||||
// session for the connection's next message.
|
// 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) {
|
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
// The queue id is known only now, and with it whether the verdict headers
|
// The queue id is known only now, and with it whether the verdict headers
|
||||||
// read on the way here are the filter's.
|
// read on the way here are the filter's.
|
||||||
@@ -196,6 +220,9 @@ const (
|
|||||||
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
|
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
|
||||||
const ListSymbolPrefix = "SELFPOST_"
|
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 (
|
var (
|
||||||
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
|
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
|
||||||
rspamdScore = regexp.MustCompile(`\[(-?[0-9]+(?:\.[0-9]+)?) / (-?[0-9]+(?:\.[0-9]+)?)\]`)
|
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.
|
// 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
|
// 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
|
// 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 {
|
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
|
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
|
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)[]"
|
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.
|
// 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.
|
// A composed header with the allow entry's -100 behind six others. rspamd
|
||||||
// Filter lists counts an entry's matches by finding that symbol in the journal:
|
// 3.11 itself sorts by absolute weight and would put it first
|
||||||
// it is kept whatever else there is.
|
// (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) {
|
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]"
|
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)
|
_, detail := rspamdVerdict("no action", result)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
// against the limit forever — a fail-closed drift this milter must not have.
|
// 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
|
// The TTL is generously longer than any realistic DATA transfer, so a message
|
||||||
// still being received is never dropped from the count.
|
// 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
|
const reservationTTL = 10 * time.Minute
|
||||||
|
|
||||||
// reservation is one message that passed the level-2 check and has not been
|
// 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
|
// 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
|
// configured with default_action=accept for this milter's socket, so even a
|
||||||
// crash or hang fails open (architecture.md § Mail path).
|
// 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
|
package milter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -45,6 +49,12 @@ type Store interface {
|
|||||||
// the queue-id at end-of-message. go-milter creates one session per
|
// the queue-id at end-of-message. go-milter creates one session per
|
||||||
// connection; a connection may carry several messages, so per-message fields
|
// connection; a connection may carry several messages, so per-message fields
|
||||||
// are reset at MailFrom (the start of every transaction).
|
// 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 {
|
type session struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
rec Store
|
rec Store
|
||||||
@@ -56,7 +66,9 @@ type session struct {
|
|||||||
// clientIP is the client's address. Postfix gives it at Connect, once per
|
// 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
|
// 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
|
// (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
|
clientIP string
|
||||||
|
|
||||||
login string
|
login string
|
||||||
@@ -68,6 +80,11 @@ type session struct {
|
|||||||
|
|
||||||
// Connect captures the client IP of a connection's first message. It is not
|
// Connect captures the client IP of a connection's first message. It is not
|
||||||
// enough on its own - see MailFrom.
|
// 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) {
|
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
s.clientIP = addr.String()
|
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
|
// 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
|
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
|
||||||
// one connection.)
|
// 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) {
|
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
||||||
if ip := clientAddr(m); ip != "" {
|
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
|
// RcptTo records each recipient. Postfix calls this once per recipient, which
|
||||||
// is what lets the journal keep a separate row per (queue-id, recipient).
|
// 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) {
|
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
||||||
return milter.RespContinue, nil
|
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
|
// 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
|
// message through just as "accept" does - and, unlike it, leaves this session
|
||||||
// in place for the connection's next message (see MailFrom).
|
// 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) {
|
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
s.record(macro(m, "i"))
|
s.record(macro(m, "i"))
|
||||||
// The rows are in the send log now, so the stored count sees this message
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
func (s *session) Abort(m *milter.Modifier) error {
|
||||||
s.releaseReservations()
|
s.releaseReservations()
|
||||||
s.rcpts = nil
|
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
|
// 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
|
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
|
||||||
// Postfix sends verbatim, so a lookup must try both forms.
|
// 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 {
|
func macro(m *milter.Modifier, name string) string {
|
||||||
if v, ok := m.Macros[name]; ok {
|
if v, ok := m.Macros[name]; ok {
|
||||||
return v
|
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
|
// 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
|
// Postfix did not send one that reads as an address. An IPv6 address may come
|
||||||
// in Sendmail's form, with "IPv6:" in front.
|
// 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 {
|
func clientAddr(m *milter.Modifier) string {
|
||||||
raw := strings.TrimSpace(macro(m, "client_addr"))
|
raw := strings.TrimSpace(macro(m, "client_addr"))
|
||||||
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
|
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
|
// 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.
|
// 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 {
|
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
||||||
flight := &inflight{} // shared: the level-2 window spans all connections
|
flight := &inflight{} // shared: the level-2 window spans all connections
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ func TestSessionResetsBetweenMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
|
// Postfix sends each macro name as milter_*_macros spells it (src/milter/
|
||||||
// {i} for some versions), so the milter must resolve those too — this is the
|
// milter.c, milter_macro_lookup()): {auth_authen} in braces, and {i} too where
|
||||||
// case the SASL-less spike missed and that produced empty app_login at first.
|
// 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) {
|
func TestBracedMacros(t *testing.T) {
|
||||||
rec := &fakeRecorder{}
|
rec := &fakeRecorder{}
|
||||||
s := &session{rec: rec}
|
s := &session{rec: rec}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ import (
|
|||||||
// "+extension", the domain. (Postfix compares logins without regard to case;
|
// "+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
|
// 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.
|
// 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
|
// tableFile is a table of the mail path that the panel rewrites and the check
|
||||||
// reads: it is read again when it has changed. An unreadable file is an error
|
// reads: it is read again when it has changed. An unreadable file is an error
|
||||||
@@ -91,6 +98,11 @@ func (b *Bindings) fresh() error {
|
|||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
// texthash: <key><whitespace><value>, the value a comma-separated
|
||||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
// 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)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
||||||
continue
|
continue
|
||||||
@@ -146,6 +158,10 @@ const extensionDelimiter = "+"
|
|||||||
// withoutExtension drops the extension of an address's local part: what
|
// withoutExtension drops the extension of an address's local part: what
|
||||||
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
||||||
// an address whose local part begins with the delimiter has no extension.
|
// 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 {
|
func withoutExtension(addr string) string {
|
||||||
at := strings.LastIndexByte(addr, '@')
|
at := strings.LastIndexByte(addr, '@')
|
||||||
if at < 0 {
|
if at < 0 {
|
||||||
@@ -184,6 +200,12 @@ func NewSigners(path string) *Signers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signs reports whether mail whose From: is at domain gets a signature.
|
// 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) {
|
func (s *Signers) Signs(domain string) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -231,6 +253,11 @@ type senderSession struct {
|
|||||||
// MailFrom starts a message and forgets the one before it: a connection may
|
// MailFrom starts a message and forgets the one before it: a connection may
|
||||||
// carry several, and after a temporary failure the library hands the
|
// carry several, and after a temporary failure the library hands the
|
||||||
// connection a new session.
|
// 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) {
|
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
s.login = macro(m, "auth_authen")
|
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.
|
// 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) {
|
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
|
||||||
shown, why, err := s.judge()
|
shown, why, err := s.judge()
|
||||||
if err != nil {
|
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
|
// 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
|
// (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.
|
// 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"))
|
s.record(shown, macro(m, "i"))
|
||||||
// A reply code of its own, so that the application is told what is wrong
|
// 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.
|
// 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 == "" {
|
if s.login == "" {
|
||||||
// Not reachable while the submission ports demand authentication; a
|
// Not reachable while the submission ports demand authentication; a
|
||||||
// message nobody answers for is not signed either way.
|
// 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
|
return "(none)", "no authenticated application", nil
|
||||||
}
|
}
|
||||||
switch len(s.froms) {
|
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
|
// 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
|
// 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.
|
// 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 {
|
func (s *senderSession) domain() string {
|
||||||
if d := domainOf(s.from); d != "" {
|
if d := domainOf(s.from); d != "" {
|
||||||
return d
|
return d
|
||||||
@@ -452,6 +496,11 @@ func isHostByte(c byte) bool {
|
|||||||
// printable reduces what a client wrote to printable ASCII without the
|
// 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:
|
// 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.
|
// 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 {
|
func printable(v string) string {
|
||||||
const max = 120
|
const max = 120
|
||||||
var sb strings.Builder
|
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
|
// 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 {
|
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
|
||||||
var refused chan store.SendLogEntry
|
var refused chan store.SendLogEntry
|
||||||
if rec != nil {
|
if rec != nil {
|
||||||
|
|||||||
@@ -13,18 +13,21 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// senderMaps is a sender_login_maps file as internal/postfix writes it: shop
|
// senderMaps is a sender_login_maps file composed by hand in the format
|
||||||
// and news send as any address of their domains, billing only as two listed
|
// internal/postfix writes (key, blank, comma-separated logins), though not in
|
||||||
// addresses of example.org, and two applications share shop.example.org.
|
// 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
|
const senderMaps = `@news.example.net news
|
||||||
@shop.example.org shop,shop2
|
@shop.example.org shop,shop2
|
||||||
invoices@example.org billing
|
invoices@example.org billing
|
||||||
billing@example.org billing
|
billing@example.org billing
|
||||||
`
|
`
|
||||||
|
|
||||||
// signingTable is OpenDKIM's SigningTable for the same server: the three
|
// signingTable is a SigningTable for the same server, composed by hand: the
|
||||||
// sending domains, one more that has no application yet, and the server's own
|
// three sending domains, one more that has no application yet, and the
|
||||||
// name.
|
// 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
|
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
|
||||||
*@shop.example.org selfpost._domainkey.shop.example.org
|
*@shop.example.org selfpost._domainkey.shop.example.org
|
||||||
*@example.org selfpost._domainkey.example.org
|
*@example.org selfpost._domainkey.example.org
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
# (internal/milter/sender_test.go) wants each refused; the e2e
|
# (internal/milter/sender_test.go) wants each refused; the e2e
|
||||||
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
|
# (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
|
# {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}"
|
||||||
"<b@{other}>"
|
"<b@{other}>"
|
||||||
"\"a@{domain}\"@{other}"
|
"\"a@{domain}\"@{other}"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
# is {domain} in capitals; {other} is another sender of the server, which a
|
# is {domain} in capitals; {other} is another sender of the server, which a
|
||||||
# line here names in a way the check's scan does not see (a space after the
|
# line here names in a way the check's scan does not see (a space after the
|
||||||
# @) - passed by design, and so signed for {domain} alone.
|
# @) - passed by design, and so signed for {domain} alone.
|
||||||
|
# The lines are composed by hand; what OpenDKIM signs each with is measured,
|
||||||
|
# by the e2e on every run.
|
||||||
"a@{domain}"
|
"a@{domain}"
|
||||||
"<a@{domain}>"
|
"<a@{domain}>"
|
||||||
"\"The Shop\" <Orders@{domain}>"
|
"\"The Shop\" <Orders@{domain}>"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
|||||||
return p.reload()
|
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) {
|
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
||||||
recipients = []byte{}
|
recipients = []byte{}
|
||||||
transport = []byte{}
|
transport = []byte{}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
|
|||||||
return p.reload()
|
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) {
|
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 })
|
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
|
// 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
|
// what disable the MX lookup, so the operator's explicit upstream is used as
|
||||||
// given; they also delimit a bare IPv6 address.
|
// 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 {
|
func inboundNexthop(host string, port int) string {
|
||||||
return "[" + host + "]:" + strconv.Itoa(port)
|
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
|
// 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
|
// a comma-separated list, which is how Postfix expects multiple owners of one
|
||||||
// sender.
|
// 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 {
|
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
|
||||||
content, err := renderSenderLoginMaps(bindings)
|
content, err := renderSenderLoginMaps(bindings)
|
||||||
if err != nil {
|
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,
|
// 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
|
// and the backslash. A login additionally may not carry "@" — that would make it
|
||||||
// look like an address.
|
// 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 (
|
const (
|
||||||
senderMapAddressForbidden = " \t\r\n,:\\"
|
senderMapAddressForbidden = " \t\r\n,:\\"
|
||||||
senderMapLoginForbidden = " \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,
|
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||||
// and it never goes through a shell (security.md).
|
// 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 {
|
func reloadViaSupervisor() error {
|
||||||
return supervisor.Start("postfix-reload")
|
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;
|
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
|
||||||
// truncated says whether there was more.
|
// 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) {
|
func listQueue() (text string, truncated bool, err error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
||||||
defer cancel()
|
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
|
// 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
|
// 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.
|
// 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) {
|
func QueueIDs() (map[string]struct{}, error) {
|
||||||
out, truncated, err := listQueue()
|
out, truncated, err := listQueue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
|
// 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
|
// 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) {
|
func TestFakePostqueue(t *testing.T) {
|
||||||
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
|
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
|
||||||
t.Skip("helper process")
|
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 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
|
// the parser must pick queue ids out of a listing and nothing else out of it:
|
||||||
// it: not the header, not the byte-count trailer, and above all not a deferred
|
// 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
|
// entry's reason line. The listing is composed by hand. Postfix 3.7.11
|
||||||
// margin just as an entry does.
|
// (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) {
|
func TestParseQueueIDs(t *testing.T) {
|
||||||
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||||
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
|
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
|
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
|
||||||
// it: where it is, and since when.
|
// 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 {
|
type QueuedMessage struct {
|
||||||
ID string
|
ID string
|
||||||
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
|
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"time"
|
"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) {
|
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"}]}
|
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"}]}
|
{"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
|
// 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
|
// build/postfix-config.sh does not set. Used when postconf is missing (unit
|
||||||
// tests, a binary outside the container) so the panel still starts.
|
// 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 {
|
func DefaultRetryPolicy() RetryPolicy {
|
||||||
return RetryPolicy{
|
return RetryPolicy{
|
||||||
QueueRunDelay: 300 * time.Second,
|
QueueRunDelay: 300 * time.Second,
|
||||||
@@ -57,6 +61,9 @@ var readRetryConf = postconfRetryValues
|
|||||||
|
|
||||||
func postconfRetryValues() ([]string, error) {
|
func postconfRetryValues() ([]string, error) {
|
||||||
// Fixed argv, no user input — same pattern as Queue (security.md).
|
// 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",
|
cmd := exec.Command("postconf", "-h",
|
||||||
"queue_run_delay",
|
"queue_run_delay",
|
||||||
"minimal_backoff_time",
|
"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
|
// 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
|
// sender, which an application may submit too. It bounds which stored log
|
||||||
// lines can be about a send-log row (store.SendLogLines).
|
// 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 {
|
func (p RetryPolicy) LongestLifetime() time.Duration {
|
||||||
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
|
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
|
||||||
return p.BounceQueueLifetime
|
return p.BounceQueueLifetime
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"time"
|
"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) {
|
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
|
||||||
old := readRetryConf
|
old := readRetryConf
|
||||||
readRetryConf = func() ([]string, error) {
|
readRetryConf = func() ([]string, error) {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
// ErrLoginExists is returned when an application login is already taken. The
|
// ErrLoginExists is returned when an application login is already taken. The
|
||||||
// login is globally unique because it is the SASL identity Postfix
|
// login is globally unique because it is the SASL identity Postfix
|
||||||
// authenticates (one sasldb2 across all domains, architecture.md § Mail path).
|
// 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")
|
var ErrLoginExists = errors.New("application login already exists")
|
||||||
|
|
||||||
// ErrApplicationNotFound is returned when an application id does not exist.
|
// ErrApplicationNotFound is returned when an application id does not exist.
|
||||||
@@ -74,6 +77,7 @@ type Binding struct {
|
|||||||
// login maps to ErrLoginExists.
|
// login maps to ErrLoginExists.
|
||||||
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
|
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Application{}, fmt.Errorf("begin add application: %w", err)
|
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)
|
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()
|
id, err := res.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Application{}, fmt.Errorf("application id: %w", err)
|
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.
|
// address list atomically (product.md). The login and password are untouched.
|
||||||
// Returns ErrApplicationNotFound if the id does not exist.
|
// Returns ErrApplicationNotFound if the id does not exist.
|
||||||
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
|
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin update application: %w", err)
|
return fmt.Errorf("begin update application: %w", err)
|
||||||
@@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteApplication removes an application and its addresses (via cascade),
|
// DeleteApplication removes an application and its addresses (via cascade, see
|
||||||
// returning the deleted application so the caller can drop its sasldb2 entry
|
// DeleteDomain), returning the deleted application so the caller can drop its
|
||||||
// (product.md). Returns ErrApplicationNotFound if no such row existed.
|
// sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed.
|
||||||
func (s *Store) DeleteApplication(id int64) (Application, error) {
|
func (s *Store) DeleteApplication(id int64) (Application, error) {
|
||||||
a, err := s.GetApplication(id)
|
a, err := s.GetApplication(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -80,8 +80,10 @@ type DMARCIngestStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InsertDMARCReport stores a parsed report and its records, replacing any prior
|
// 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) {
|
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
|
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
|
// DeleteDomain removes a domain. Its applications and their address/binding rows
|
||||||
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
|
// 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 {
|
func (s *Store) DeleteDomain(id int64) error {
|
||||||
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
|
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict.
|
// 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 {
|
func isForeignKeyViolation(err error) bool {
|
||||||
var se *sqlite.Error
|
var se *sqlite.Error
|
||||||
return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
|
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.
|
// 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
|
// 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) {
|
func (s *Store) DomainExists(name string) (bool, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type InboundDomain struct {
|
|||||||
// caller must have validated name (security.md).
|
// caller must have validated name (security.md).
|
||||||
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
|
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
|
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
|
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
|
||||||
// address list. In any mode the stored list is cleared.
|
// address list. In any mode the stored list is cleared.
|
||||||
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
|
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin update inbound recipients: %w", err)
|
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
|
// 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
|
// 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
|
// 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) {
|
func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) {
|
||||||
at := e.At.UTC().Format(time.RFC3339)
|
at := e.At.UTC().Format(time.RFC3339)
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
@@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
|
|||||||
n, _ := res.RowsAffected()
|
n, _ := res.RowsAffected()
|
||||||
// Oldest by when it happened, not by id: the tailer dates a refusal by its
|
// 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.
|
// 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(
|
res, err = s.db.Exec(
|
||||||
`DELETE FROM inbound_spam_log WHERE id IN
|
`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)
|
(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
|
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
|
||||||
// states of subjects that are gone - a deleted domain - can be found.
|
// 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) {
|
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
|
"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.
|
// reason and is still judged the old way.
|
||||||
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
||||||
// The status is a literal so that the partial index of migration 0005
|
// 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(
|
rows, err := s.db.Query(
|
||||||
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
|
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
|
||||||
FROM send_log l
|
FROM send_log l
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
|
|||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("sender check deferred: %w", err)
|
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
|
// 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
|
// 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")
|
stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z")
|
||||||
setting := func(key, value, cond string) error {
|
setting := func(key, value, cond string) error {
|
||||||
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
|
_, 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
|
// 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
|
// (audit 2026-10-12, L12). The domain name of the row stays what the
|
||||||
// envelope said.
|
// 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 = ?)`
|
const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)`
|
||||||
|
|
||||||
// InsertQueued records an accepted message in the send log with status
|
// 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
|
// first and then writes fails at once with "database is locked" when another
|
||||||
// connection - the dmarc-ingest process opens the same file - commits in
|
// 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,
|
// 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) {
|
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
@@ -184,7 +194,9 @@ type QueuedDelivery struct {
|
|||||||
// about them.
|
// about them.
|
||||||
//
|
//
|
||||||
// created_at is stored as RFC3339 UTC, so a lexical comparison against the same
|
// 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) {
|
func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, queue_id, to_addr FROM send_log
|
`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
|
// 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
|
// 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.
|
// 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) {
|
func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) {
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`UPDATE send_log SET refusal_line = ?1 WHERE id = (
|
`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
|
// 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
|
// 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
|
// 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) {
|
func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) {
|
||||||
stamp := at.UTC().Format(time.RFC3339)
|
stamp := at.UTC().Format(time.RFC3339)
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
@@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
|
|||||||
// implementing the configurable retention window (architecture.md §
|
// implementing the configurable retention window (architecture.md §
|
||||||
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
|
// 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
|
// 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) {
|
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`DELETE FROM send_log WHERE created_at < ?`,
|
`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,
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// LookupSession returns the session for tokenHash, if any. It does not check
|
||||||
// expiry itself — callers compare ExpiresAt against time.Now() and call
|
// 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) {
|
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
|
||||||
var (
|
var (
|
||||||
row SessionRow
|
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
|
// abandoned session (cookie never presented again) does not sit in the table
|
||||||
// forever. It piggybacks on session creation rather than running as its own
|
// forever. It piggybacks on session creation rather than running as its own
|
||||||
// background loop.
|
// background loop.
|
||||||
|
// expires_at is RFC3339 UTC text, so < compares moments (see
|
||||||
|
// ListQueuedOlderThan).
|
||||||
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
|
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))
|
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) {
|
|||||||
return value, nil
|
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 {
|
func (s *Store) SetSetting(key, value string) error {
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
"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,
|
// 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
|
// 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.
|
// 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)"
|
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
|
// 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)
|
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
|
var peak int64
|
||||||
err = s.db.QueryRow(
|
err = s.db.QueryRow(
|
||||||
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
|
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
|
||||||
|
|||||||
+26
-2
@@ -29,13 +29,23 @@ type Store struct {
|
|||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
// _pragma parameters are applied on every pooled connection by the driver,
|
// _pragma parameters are applied on every pooled connection by the driver,
|
||||||
// so foreign-key enforcement and WAL survive connection churn.
|
// 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)
|
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
|
||||||
db, err := sql.Open("sqlite", dsn)
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open database: %w", err)
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
}
|
}
|
||||||
// modernc's driver serializes writes anyway; a small pool avoids
|
// One connection: database/sql then runs this process's statements and
|
||||||
// "database is locked" surprises under WAL.
|
// 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)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
s := &Store{db: db}
|
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
|
// 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
|
// on a schema it does not know, so such a file is refused outright; nothing
|
||||||
// is migrated from 1.x (docs/schema-migrations.md).
|
// 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 {
|
if version > 0 {
|
||||||
var appID int64
|
var appID int64
|
||||||
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
|
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
|
||||||
@@ -109,12 +123,22 @@ func (s *Store) migrate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
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 {
|
if _, err := tx.Exec(string(sqlBytes)); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
// PRAGMA does not accept a bound parameter, and target is a trusted
|
// PRAGMA does not accept a bound parameter, and target is a trusted
|
||||||
// loop index, so formatting it in is safe.
|
// 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 {
|
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return fmt.Errorf("bump schema version for %s: %w", name, err)
|
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
|
// 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
|
// 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).
|
// 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 {
|
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
@@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) {
|
|||||||
if c.Role == RoleDomain && c.Reach.Empty() {
|
if c.Role == RoleDomain && c.Reach.Empty() {
|
||||||
return 0, ErrEmptyReach
|
return 0, ErrEmptyReach
|
||||||
}
|
}
|
||||||
|
// The first statement is a write (see UpdateStatus).
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("create user: %w", err)
|
return 0, fmt.Errorf("create user: %w", err)
|
||||||
@@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
// A write first: it takes the write lock, so what is counted below is
|
// 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
|
// 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.
|
// opened, and the user may have changed it on Account since.
|
||||||
res, err := tx.Exec(
|
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
|
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global
|
||||||
// user: the delete and the count are one transaction, so two administrators
|
// 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 {
|
func (s *Store) DeleteUser(id int64) error {
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
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
|
// 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
|
// 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
|
// 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 {
|
func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error {
|
||||||
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
|
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
|
||||||
reach.AllDomains, reach.AllInbound, userID); err != nil {
|
reach.AllDomains, reach.AllInbound, userID); err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"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 (
|
const (
|
||||||
sessionCookieBase = "selfpost_session"
|
sessionCookieBase = "selfpost_session"
|
||||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||||
@@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
|||||||
case 1:
|
case 1:
|
||||||
return token, true
|
return token, true
|
||||||
default:
|
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; "+
|
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",
|
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||||
r.Method, r.URL.Path, n, name)
|
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)
|
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) {
|
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: m.sessionCookie(),
|
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 {
|
func isSessionActivity(r *http.Request) bool {
|
||||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
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
|
// 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
|
// 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 {
|
func (s *sessionStore) MaxAge() int {
|
||||||
return int(s.idle.Seconds())
|
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.Username = strings.TrimSpace(r.PostFormValue("username"))
|
||||||
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||||
// An unticked checkbox is not sent at all: absent is off.
|
// 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") != ""
|
f.Notify = r.PostFormValue("notify") != ""
|
||||||
if f.Username == "" {
|
if f.Username == "" {
|
||||||
f.Username = u.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
|
// 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.
|
// 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) {
|
func noStore(w http.ResponseWriter) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Pragma", "no-cache")
|
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.
|
// 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") != ""
|
notifications := r.PostFormValue("notify") != ""
|
||||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||||
logf("panel: settings: set send-log retention failed: %v", err)
|
logf("panel: settings: set send-log retention failed: %v", err)
|
||||||
|
|||||||
@@ -19,8 +19,17 @@ import (
|
|||||||
// relative URL on the page, including the form actions;
|
// relative URL on the page, including the form actions;
|
||||||
// - form-action 'self' keeps a form from being retargeted at another host.
|
// - 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
|
// 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'; " +
|
const contentSecurityPolicy = "default-src 'self'; " +
|
||||||
"object-src 'none'; " +
|
"object-src 'none'; " +
|
||||||
"base-uri 'none'; " +
|
"base-uri 'none'; " +
|
||||||
@@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " +
|
|||||||
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
|
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
|
||||||
// on the plain-HTTP development setup it would be meaningless at best and
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// 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")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
|
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
|
||||||
// that predate it. The two never disagree: both mean "never framed".
|
// 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")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
// The panel's behaviour never depends on the referrer, so the strictest
|
// The panel's behaviour never depends on the referrer, so the strictest
|
||||||
// value is free: panel URLs carry domain names and record identifiers
|
// 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
|
// being the name the browser actually used, which every reverse
|
||||||
// proxy fragment this project ships preserves (Apache
|
// proxy fragment this project ships preserves (Apache
|
||||||
// ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy
|
// 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
|
// every form submission into a 403, and without these two values
|
||||||
// side by side the symptom just looks like "the panel stopped
|
// side by side the symptom just looks like "the panel stopped
|
||||||
// saving anything".
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// set by page script (a forbidden header name), so when it is present it
|
||||||
// is the answer: anything but same-origin — including "none", a
|
// 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 != "" {
|
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||||
return site == "same-origin"
|
return site == "same-origin"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older browsers send only Origin. Compare hosts, not full origins: the
|
// Older browsers send only Origin, and so does a current one over plain
|
||||||
// panel speaks plain HTTP behind the proxy and does not know its own
|
// HTTP at a name other than localhost: Fetch Metadata § 3 appends its
|
||||||
// external scheme, while Origin will say https.
|
// 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")
|
origin := r.Header.Get("Origin")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
// Neither header. A client this old cannot be checked at all; it is
|
// Neither header. A client this old cannot be checked at all; it is
|
||||||
// let through as the risk consciously accepted (single-admin panel,
|
// let through as the risk consciously accepted (single-admin panel,
|
||||||
// the administrator picks the browser). Turning this return into
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
u, err := url.Parse(origin)
|
u, err := url.Parse(origin)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
// Unparseable, or the opaque "null" origin (sandboxed iframe,
|
// Unparseable, or the opaque "null" origin (sandboxed iframe,
|
||||||
// redirected cross-origin POST) — not this panel either way.
|
// 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 false
|
||||||
}
|
}
|
||||||
return u.Host == r.Host
|
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:
|
// 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.
|
// 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.
|
// 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 {
|
type Sheet struct {
|
||||||
ID string
|
ID string
|
||||||
Tag Tag
|
Tag Tag
|
||||||
@@ -163,7 +169,9 @@ func StatusTag(status string) string {
|
|||||||
|
|
||||||
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
|
// 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
|
// 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 {
|
func wbrAt(s string) template.HTML {
|
||||||
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
|
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
|
||||||
}
|
}
|
||||||
@@ -283,6 +291,10 @@ type Box struct {
|
|||||||
Poll string
|
Poll string
|
||||||
// OOB marks the box to be swapped into the page by its ID when it comes
|
// 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.
|
// 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
|
OOB bool
|
||||||
// DomainPick: see Box.
|
// DomainPick: see Box.
|
||||||
DomainPick bool
|
DomainPick bool
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import (
|
|||||||
// a response carries no validator at all and the browser is free to guess how
|
// 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 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
|
// 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()
|
var staticETags = buildStaticETags()
|
||||||
|
|
||||||
func buildStaticETags() map[string]string {
|
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
|
// 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.
|
// round — correctness after a deploy matters more than saving the round trip.
|
||||||
// http.ServeContent answers the conditional request from the ETag we set here.
|
// 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 {
|
func StaticHandler() http.Handler {
|
||||||
files := http.FileServer(http.FS(assetsFS))
|
files := http.FileServer(http.FS(assetsFS))
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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