build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)

Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:39:21 +03:00
co-authored by Claude Opus 5.5
parent ad3d834392
commit 599149bf83
39 changed files with 417 additions and 59 deletions
@@ -24,6 +24,10 @@ services:
- antispam
antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
# (its Dockerfile, USER 11333:11333).
image: rspamd/rspamd:3.11
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
@@ -17,6 +17,18 @@
# SelfPost reads a verdict only next to a signature it can recompute. The
# signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id.
#
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
# as function(task, common_meta) and returns error, headers to add, headers to
# remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
# "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
custom {
+16 -1
View File
@@ -3,6 +3,11 @@
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
# writes them as six files under data/antispam/, which the fragment mounts at
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
# ev_stat - inotify where the kernel has it, else a stat poll every
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
# internal/inbound writes with.)
#
# Deny wins over allow, whichever entries match. A denied sender is refused
# before anything else is looked at (a prefilter). An allowed one is not
@@ -10,7 +15,17 @@
# first decide - but given a weight no spam score outweighs, which a refusal
# by the deny list still overrides.
#
# A domain entry is that domain, not its subdomains.
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
#
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
# with the rule's action and message, and that action stands whatever the score
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
# puts it into messages.smtp_message, src/libserver/milter.c,
# rspamd_milter_send_task_results(), replies with it.
#
# SelfPost counts how often an entry decided a message by what these rules
# leave behind: the deny message below and the names of the allow symbols.
+9 -1
View File
@@ -11,10 +11,18 @@
# storage detail, not a documented public API — VERIFY the exact path against
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
# before wiring it into the volume mount below.
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
+7
View File
@@ -116,6 +116,13 @@ services:
# FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container
# crash-loops on start.
# Each capability above as capabilities(7) defines it (man7.org,
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
# no-new-privileges only changes execve() (Linux,
# Documentation/userspace-api/no_new_privs.rst): root may still call
# setuid()/setgid() with these capabilities.
security_opt:
- no-new-privileges:true
cap_drop:
+6 -1
View File
@@ -8,7 +8,9 @@
# takes over 80/443 and proxies to the panel over the compose network
# instead). Merge it with the base file — run this from the deploy/ directory
# (Compose resolves every relative path in both files against the directory of
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
#
@@ -54,4 +56,7 @@ services:
- ./nginx/certbot-www:/var/www/certbot
# Renewal twice a day is certbot's own recommended cadence; it no-ops
# until a certificate is within its renewal window.
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
# certificate near expiry.)
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
+2
View File
@@ -17,6 +17,8 @@ server {
server {
listen 443 ssl;
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
http2 on;
server_name mail.example.com;
+7 -2
View File
@@ -6,7 +6,9 @@
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
#
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
@@ -25,7 +27,10 @@ services:
# Traefik only proxies the panel; it never sees the mail ports.
# These live on the selfpost service (not traefik's) because the
# docker provider reads routing labels off the container being routed
# to, not off traefik itself.
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
# docker/, "Routing Configuration": "Traefik uses container labels";
# with exposedByDefault=false only containers labelled
# traefik.enable=true.)
- traefik.enable=true
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
- traefik.http.routers.selfpost.entrypoints=websecure
+7
View File
@@ -6,6 +6,13 @@
# by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
#
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
# docker-compose.traefik.yml names.
set -eu
ACME_JSON="${1:?path to acme.json}"