review follow-up: close the gaps left by the phase1-6 pass
test / test (push) Has been cancelled

Verified the tree against docs/plans/code-review-2026-08.md and finished the
tasks that were only partly done, plus two defects the pass introduced.

- bcrypt: the setup account was still hashed at DefaultCost (10). The work
  factor now has one definition, auth.BcryptCost = 12, used everywhere.
- rate limiter: protecting blocked buckets from eviction was unbounded — an
  attacker with many source addresses could grow the map by making each one
  fail its way to blocked. Blocked buckets are now evicted past
  maxBuckets * 4, nearest expiry first, logged once per window. Tests cover
  both halves.
- postqueue: the 5s timeout left the output size unbounded; reads are now
  capped at 4 MiB and report truncation.
- spec references: the ~76 `spec N` citations CHANGELOG [0.5.0] claimed were
  gone are removed from build/, deploy/, the first two migrations and the
  release workflow, with the affected comment paragraphs re-wrapped. Comments
  only; shell/YAML/SQL syntax re-checked.
- schema-migrations.md: schema section was headed v9 with the chain at v10,
  and three links pointed at deleted plan files.
- internal/configsafe: the shared empty/forbidden-character check behind the
  OpenDKIM and Postfix config writers; each writer keeps its own forbidden set.
- dropped the parseDomainRateLimitForm/parseAppRateLimitForm pass-throughs
  left behind by the parser merge (only their own tests still called them).
- plan doc now carries a status table: what shipped, what was already in
  place, what was declined (//go:build linux for the /proc reader) and the
  open CSRF-token question.

Not done, each its own phase: typed template data structs, log/slog, DMARC
service and handler tests, error-page/corrupted-backup tests, backup re-auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-08-19 20:03:05 +03:00
co-authored by Claude Opus 5
parent 4204ee279c
commit ab2cd885c4
34 changed files with 586 additions and 318 deletions
+10 -10
View File
@@ -1,19 +1,19 @@
name: release name: release
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # Publishes an immutable, version-tagged image on ghcr.io.
# Ordinary commits and bare git tag pushes do not publish anything. A published # Ordinary commits and bare git tag pushes do not publish anything. A published
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub). # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z. # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# #
# Native per-architecture builds (see docs/development.md), not qemu: # Native per-architecture builds (see docs/development.md), not qemu: running
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate # the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
# below is impractically slow. Each arch builds, e2e-gates and pushes its own # impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# tag on its own native runner; a merge job then combines them into the one # own native runner; a merge job then combines them into the one manifest tag
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
# push-by-digest then test) is deliberate: it means the bytes that get tagged # then test) is deliberate: it means the bytes that get tagged are exactly the
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so # bytes that passed e2e. Per-arch tags are pushed only so imagetools can
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR # assemble the multi-arch manifest; merge removes them from GHCR so operators
# so operators see a single version tag (spec 10.1). # see a single version tag.
on: on:
release: release:
types: [published] types: [published]
+35 -10
View File
@@ -15,16 +15,8 @@ rate limit is correct for a non-default level-1 window. Schema migration
- Code review plan (`docs/plans/code-review-2026-08.md`) — full codebase audit - Code review plan (`docs/plans/code-review-2026-08.md`) — full codebase audit
covering architecture, code quality, security, GUI, tests, documentation, and covering architecture, code quality, security, GUI, tests, documentation, and
AGPL compliance. 38 findings across 11 categories with a prioritised AGPL compliance. 38 findings across 11 categories with a prioritised
implementation plan in 6 phases. implementation plan in 6 phases, and a status table recording what was
implemented, what was already in place, and what was declined.
### Removed
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
### Added
- Roadmap item and plan for `/preflight` installation check page - Roadmap item and plan for `/preflight` installation check page
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level ([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers, infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
@@ -46,6 +38,21 @@ rate limit is correct for a non-default level-1 window. Schema migration
### Fixed ### Fixed
- The setup account's password is hashed at bcrypt cost `12` like every other
panel password; it was still being written at `bcrypt.DefaultCost` (10). The
work factor now has one definition, `auth.BcryptCost`. bcrypt records the
cost in the hash, so existing passwords keep verifying unchanged.
- Login/setup rate limiter: protecting blocked buckets from eviction is now
bounded. Blocked buckets are still never evicted to make room, but past
`maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an
attacker with many source addresses could grow the bucket map without limit
by making each address fail its way to blocked. The overflow is logged at
most once per window.
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
the listing, and therefore the panel's allocation, up to the queue).
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
still headed `v9` while the chain head is `v10`, and three links pointed at
plan files deleted when those features shipped.
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
context timeout so a stuck queue command cannot hang the queue panel path context timeout so a stuck queue command cannot hang the queue panel path
indefinitely. indefinitely.
@@ -96,6 +103,18 @@ rate limit is correct for a non-default level-1 window. Schema migration
### Changed ### Changed
- Extracted `internal/configsafe` — the empty-value and forbidden-character
check every generated config file shares. Each writer keeps its own forbidden
set next to the file it protects (an OpenDKIM table and a Postfix map break on
different characters); only the check itself is shared.
- Build, deploy and migration comments no longer cite the archived
specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone
survived in `build/`, `deploy/`, the first two migrations and the release
workflow; they are removed and the affected comment paragraphs re-wrapped.
Comments only — no build or configuration behaviour changes.
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
wrappers left behind when the two parsers were merged: nothing but their own
tests still called them.
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
`internal/supervisor` (supervisorctl start/signal wrappers), and reused them in `internal/supervisor` (supervisorctl start/signal wrappers), and reused them in
domain/postfix code paths. domain/postfix code paths.
@@ -130,6 +149,12 @@ rate limit is correct for a non-default level-1 window. Schema migration
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md) version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
last-updated note. No behaviour change. last-updated note. No behaviour change.
### Removed
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
## [1.9.1] - 2026-08-19 ## [1.9.1] - 2026-08-19
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
+17 -17
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# SelfPost — single Debian-slim image running postfix + opendkim + panel under # SelfPost — single Debian-slim image running postfix + opendkim + panel under
# supervisord (spec 4). Build from the repository root: # supervisord. Build from the repository root:
# #
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
@@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build
WORKDIR /src WORKDIR /src
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). # Version stamped into both binaries; MUST match the image tag.
ARG VERSION=dev ARG VERSION=dev
# Module metadata first for layer caching. # Module metadata first for layer caching.
@@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process (spec 7.6.8). # Unprivileged user for the panel process.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel # Shared group bridging the unprivileged services: the panel generates
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the # per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# Postfix sender map, while OpenDKIM and Postfix (different users) must read # sender map, while OpenDKIM and Postfix (different users) must read them.
# them. Membership in this group — plus setgid dirs under /data (set up in # Membership in this group — plus setgid dirs under /data (set up in
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read # entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
# the sasldb2/sender map, and lets the panel reach the supervisor control socket # the sasldb2/sender map, and lets the panel reach the supervisor control socket
# to signal OpenDKIM/Postfix reloads without any process running as root. # to signal OpenDKIM/Postfix reloads without any process running as root.
@@ -67,15 +67,15 @@ RUN groupadd --system selfpost \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the # The panel reads the mail queue with `postqueue -p` (and the status page's
# status page's queue card). postqueue is setgid postdrop, which normally gives # queue card). postqueue is setgid postdrop, which normally gives it the group
# it the group needed to reach Postfix's showq socket — but the documented # needed to reach Postfix's showq socket — but the documented deployment runs
# deployment runs with `no-new-privileges`, which disables setgid transitions, # with `no-new-privileges`, which disables setgid transitions, so the panel
# so the panel would always see "Permission denied". Making `panel` a real # would always see "Permission denied". Making `panel` a real member of postdrop
# member of postdrop grants the same access without relying on a setgid # grants the same access without relying on a setgid escalation the hardening
# escalation the hardening deliberately forbids. postdrop membership is # deliberately forbids. postdrop membership is read-side only: it does not let
# read-side only: it does not let the panel bypass any Postfix restriction that # the panel bypass any Postfix restriction that a local user does not already
# a local user does not already have through the world-executable sendmail. # have through the world-executable sendmail.
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.
@@ -118,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
# The entrypoint fixes /data ownership (bind mount) as root, then execs # The entrypoint fixes /data ownership (bind mount) as root, then execs
# supervisord, which becomes PID 1 and owns process supervision (spec 4). # supervisord, which becomes PID 1 and owns process supervision.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+1 -1
View File
@@ -4,7 +4,7 @@
If any managed program exhausts its restart retries and enters FATAL, this If any managed program exhausts its restart retries and enters FATAL, this
listener signals supervisord (its parent) to terminate, so the whole container listener signals supervisord (its parent) to terminate, so the whole container
exits and Docker's `restart` policy can recreate it cleanly — rather than exits and Docker's `restart` policy can recreate it cleanly — rather than
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4). 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.
+25 -25
View File
@@ -9,14 +9,14 @@ set -e
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must # SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the # simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and # Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
# postfix-config.sh each fall back independently when it's unset — to # fall back independently when it's unset — to `localhost` and to the container
# `localhost` and to the container hostname respectively — so accounts get # hostname respectively — so accounts get written under one realm and looked up
# written under one realm and looked up under another and authentication # under another and authentication silently fails for every application, while
# silently fails for every application, while HELO also stops matching the # HELO also stops matching the PTR record and mail that does go out lands in
# PTR record and mail that does go out lands in spam. No fallback can be # spam. No fallback can be correct, so fail loudly here, before either side of
# correct, so fail loudly here, before either side of that split has a chance # that split has a chance to run, rather than leave a green panel with broken
# to run, rather than leave a green panel with broken mail. # mail.
if [ -z "$SELFPOST_HOSTNAME" ]; then if [ -z "$SELFPOST_HOSTNAME" ]; then
cat >&2 <<'EOF' cat >&2 <<'EOF'
FATAL: SELFPOST_HOSTNAME is not set. FATAL: SELFPOST_HOSTNAME is not set.
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
;; ;;
esac esac
# The persistent root /data is a host bind mount (spec 9), so it arrives owned # The persistent root /data is a host bind mount, so it arrives owned by the
# by the host user (typically root), not by the unprivileged panel user that # host user (typically root), not by the unprivileged panel user that actually
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). # writes the SQLite database, setup token and DKIM keys. Fix its ownership here
# Fix its ownership here — the one place still running as root — before handing # — the one place still running as root — before handing off to supervisord,
# off to supervisord, which starts the panel as the panel user. # which starts the panel as the panel user.
# #
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their # Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare # trees under /data as other users. Go's testing.TempDir is 0700, and a bare
@@ -71,7 +71,7 @@ chmod 755 /data
# normalised on its own below. # normalised on its own below.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} + find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes # DKIM key tree. The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# tree on every start so it is correct whether /data is fresh, restarted, or # tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup: # just restored from a backup:
@@ -89,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the # Application SASL accounts. The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP # sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
# clients. Share it through the `selfpost` group the same way as the DKIM tree: # clients. Share it through the `selfpost` group the same way as the DKIM tree:
# setgid directory so new files inherit the group, and the database itself # setgid directory so new files inherit the group, and the database itself
@@ -99,8 +99,8 @@ chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# Postfix state under /data (spec 5.1, architecture.md § Persistence). The panel # Postfix state under /data (architecture.md § Persistence). The panel writes
# writes sender_login_maps; Postfix owns the on-disk queue tree under queue/. # sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/queue mkdir -p /data/postfix/queue
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true). # Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
@@ -132,14 +132,14 @@ chown -R postfix:selfpost /data/log
chmod 2750 /data/log chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} + find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must # Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's # both milter sockets — OpenDKIM's and the panel's journal-milter — not just
# journal-milter — not just probe them at start-up. The sockets are # probe them at start-up. The sockets are created by the opendkim and panel
# created by the opendkim and panel users respectively, so bridge them to # users respectively, so bridge them to `postfix` through the shared `selfpost`
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean # group: group-owned + setgid dirs mean each socket created inside inherits
# each socket created inside inherits group `selfpost`, and group-traversable # group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and, # this, smtpd cannot talk to OpenDKIM and, because signing is strict
# because signing is strict (default_action=tempfail), rejects all mail. # (default_action=tempfail), rejects all mail.
mkdir -p /run/opendkim /run/selfpost mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost chown panel:selfpost /run/selfpost
+7 -7
View File
@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the # Periodic logrotate for /data/log/mail.log. Rotation renames the file,
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container # recreates it (`create 0640 postfix selfpost`, matching a cold container
# start), then runs `postfix reload` (the same mechanism `postfix logrotate` # start), then runs `postfix reload` (the same mechanism `postfix logrotate`
# uses): postlogd keeps writing to the renamed inode until reload, and the # uses): postlogd keeps writing to the renamed inode until reload, and the
# panel's log-tailer holds its own descriptor on that inode, so nothing # panel's log-tailer holds its own descriptor on that inode, so nothing written
# written before the reload is lost. `create` (rather than `nocreate`) matters # before the reload is lost. `create` (rather than `nocreate`) matters here
# here beyond timing: a postlogd-triggered recreate lands the file at 0600 # beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# owned by postfix, which the unprivileged panel process cannot read — # postfix, which the unprivileged panel process cannot read — confirmed on a
# confirmed on a live container — so logrotate must be the one to create it. # live container — so logrotate must be the one to create it.
# #
# 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
+2 -2
View File
@@ -1,9 +1,9 @@
# OpenDKIM — per-domain signing (spec 6). # OpenDKIM — per-domain signing.
# #
# 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 (spec 6.2, 6.5). # supervisord to send this process SIGUSR1 to reload the tables.
# #
# 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
+49 -50
View File
@@ -1,16 +1,16 @@
#!/bin/sh #!/bin/sh
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2). # Generate the outbound-relay Postfix configuration.
# #
# Run as root from entrypoint.sh on every container start, BEFORE supervisord # Run as root from entrypoint.sh on every container start, BEFORE supervisord
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is # starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
# re-derived from the environment on each start rather than persisted, so a # re-derived from the environment on each start rather than persisted, so a
# fresh, restarted or restored container always ends up with the same config # fresh, restarted or restored container always ends up with the same config
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and # (the only panel-edited Postfix file, sender_login_maps, lives under /data and
# is generated separately by the panel — spec 5.1). # is generated separately by the panel).
# #
# main.cf is written with `postconf -e`, the master.cf submission services with # main.cf is written with `postconf -e`, the master.cf submission services with
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from # `postconf -M`/`-P`. No user input is interpolated: every value here comes from
# a fixed literal or a container environment variable (spec 7.6.3). # a fixed literal or a container environment variable.
set -eu set -eu
# postconf -P refuses master.cf -o values that contain whitespace; inbound port # postconf -P refuses master.cf -o values that contain whitespace; inbound port
@@ -44,16 +44,16 @@ master_cf_set_smtp_inet_o() {
mv /etc/postfix/master.cf.new /etc/postfix/master.cf mv /etc/postfix/master.cf.new /etc/postfix/master.cf
} }
# --- environment knobs (spec 8) ---------------------------------------------- # --- environment knobs -------------------------------------------------------
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the # Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
# 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.
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 # TLS material supplied by the reverse-proxy through a read-only bind mount. The
# (spec 5.2). The relay requires TLS on 465; if these files are absent the # relay requires TLS on 465; if these files are absent the master still starts
# master still starts but TLS handshakes on 465 fail until they appear. # but TLS handshakes on 465 fail until they appear.
# #
# 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
@@ -75,8 +75,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem" TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
fi fi
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative # Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
# defaults, sensible during IP warm-up (spec 10). # during IP warm-up.
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
@@ -85,16 +85,16 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so # Persistent panel-managed sender map; texthash needs no postmap, so the
# the unprivileged panel can rewrite it and just ask for a reload. # unprivileged panel can rewrite it and just ask for a reload.
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.
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}" QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only # Optional submission service on 587 (off by default, enabled only when a client
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). # library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does # Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
@@ -153,13 +153,13 @@ postconf -e \
"alias_maps=" \ "alias_maps=" \
"alias_database=" "alias_database="
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2). # Outbound delivery: straight to the recipient MX, opportunistic TLS.
postconf -e \ postconf -e \
"smtp_tls_security_level=may" \ "smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes" "smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service (spec 5.2). auth_only # TLS server material shared by every inbound service. auth_only guarantees
# guarantees credentials are never accepted before TLS is up on any port. # credentials are never accepted before TLS is up on any port.
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}" \
@@ -167,11 +167,11 @@ postconf -e \
"smtpd_tls_auth_only=yes" \ "smtpd_tls_auth_only=yes" \
"smtpd_tls_loglevel=1" "smtpd_tls_loglevel=1"
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm # SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name # implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
# Postfix uses for sender_login_maps is the BARE login the panel writes into the # uses for sender_login_maps is the BARE login the panel writes into the map;
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname # the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# 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.
postconf -e \ postconf -e \
"smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \ "smtpd_sasl_type=cyrus" \
@@ -181,43 +181,42 @@ 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 (spec 5.1 p.3, the critical anti-spoofing control). texthash # Sender binding (the critical anti-spoofing control). texthash resolves the
# resolves the full address first, then the "@domain" wildcard, so both address # full address first, then the "@domain" wildcard, so both address modes work
# modes work from the same map. # from the same map.
postconf -e \ postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \ "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"queue_directory=${QUEUE_DIR}" "queue_directory=${QUEUE_DIR}"
# 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 network # permit_mynetworks anywhere — authorisation is by credentials, never by
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible. # network. This is what makes an open relay impossible.
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" \
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit" "smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even # Level-1 rate limit by client IP. Backstop that keeps working even if the
# if the journal-milter (level 2) is down. # journal-milter (level 2) is down.
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"
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly # Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
# (default_action=tempfail: if it is unreachable, defer rather than send # if it is unreachable, defer rather than send unsigned). The journal-milter is
# unsigned). The journal-milter is monitoring only and is fail-open # monitoring only and is fail-open (default_action=accept): its failure must
# (default_action=accept): its failure must never block the relay. Per-milter # never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
# settings use Postfix 3.0+ brace syntax.
postconf -e \ postconf -e \
"milter_protocol=6" \ "milter_protocol=6" \
"milter_default_action=tempfail" \ "milter_default_action=tempfail" \
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ "smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters=" "non_smtpd_milters="
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never # Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
# replies) must fail open just like a crash, not stall mail acceptance until the # must fail open just like a crash, not stall mail acceptance until the Postfix
# Postfix defaults (300s content) elapse. With default_action per milter, a # defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # journal-milter hang then resolves to accept and 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
@@ -228,8 +227,8 @@ postconf -e \
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}" "milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
# --- master.cf: inbound submission services ---------------------------------- # --- master.cf: inbound submission services ----------------------------------
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service # smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# (spec 5 p.1). 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.
postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \ postconf -P \
@@ -237,10 +236,10 @@ postconf -P \
"smtps/inet/smtpd_sasl_auth_enable=yes" \ "smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits; # submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# the only difference is TLS is negotiated via STARTTLS, so require encryption # difference is TLS is negotiated via STARTTLS, so require encryption before
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a # auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# restart after disabling it does not leave the port listening. # after disabling it does not leave the port listening.
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 \
@@ -364,18 +363,18 @@ else
"smtp_tls_policy_maps=" "smtp_tls_policy_maps="
fi fi
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery # Disable chroot for every service. Debian ships the smtp delivery agent and
# agent and others chrooted to /var/spool/postfix, where they cannot read # others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail # — so outbound MX lookups fail with "Host not found" and mail never leaves.
# never leaves. Inside a container the chroot buys little (the container is the # Inside a container the chroot buys little (the container is the isolation
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off # boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
# uniformly. Our own smtps/submission services are already n; this covers the # own smtps/submission services are already n; this covers the delivery agents
# delivery agents and the rest. # and the rest.
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 (spec 5.1). 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.
mkdir -p /etc/postfix/sasl mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF cat > /etc/postfix/sasl/smtpd.conf <<EOF
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would # Postfix start wrapper: solves the cold-start race where Postfix would try to
# try to reach the milter sockets before they are listening. # reach the milter sockets before they are listening.
# #
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter # It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
# — are present, then execs `postfix start-fg`. If they are not ready within the # — are present, then execs `postfix start-fg`. If they are not ready within the
+17 -17
View File
@@ -1,4 +1,4 @@
; SelfPost process supervision (spec 4). ; SelfPost process supervision.
; ;
; 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 journal-milter socket), then the Postfix wrapper — which additionally ; the journal-milter socket), then the Postfix wrapper — which additionally
@@ -18,8 +18,8 @@ loglevel=info
; The control socket is reachable by the shared `selfpost` group so the panel ; The control socket is reachable by the shared `selfpost` group so the panel
; (running as the unprivileged `panel` user) can ask supervisord to send ; (running as the unprivileged `panel` user) can ask supervisord to send
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is ; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
; 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.
[unix_http_server] [unix_http_server]
@@ -67,15 +67,15 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and ; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; cannot run `postfix reload` itself, and it cannot signal the Postfix master ; reload` itself, and it cannot signal the Postfix master directly: `postfix
; directly: `postfix start-fg` forks a separate master process, so a signal to ; start-fg` forks a separate master process, so a signal to the supervised
; the supervised foreground process never reaches it (unlike OpenDKIM, which ; foreground process never reaches it (unlike OpenDKIM, which runs in the
; runs in the foreground as the supervised process itself). Instead the panel ; foreground as the supervised process itself). Instead the panel asks
; asks supervisord — over the group-accessible control socket — to run this ; supervisord — over the group-accessible control socket — to run this program,
; program, which executes the canonical `postfix reload` as root. autostart is ; which executes the canonical `postfix reload` as root. autostart is off (it
; off (it only runs on demand) and a fast, clean exit is expected, so it never ; only runs on demand) and a fast, clean exit is expected, so it never trips the
; trips the crashexit listener. ; crashexit listener.
[program:postfix-reload] [program:postfix-reload]
command=/usr/sbin/postfix reload command=/usr/sbin/postfix reload
autostart=false autostart=false
@@ -88,7 +88,7 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload` ; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
; so certificates the reverse-proxy renews in the read-only mount are picked up. ; so certificates the reverse-proxy renews in the read-only mount are picked up.
; Long-running (it loops), runs as root so it can reload Postfix, and never exits ; Long-running (it loops), runs as root so it can reload Postfix, and never exits
; non-zero, so it neither trips the crashexit listener nor needs restarting. ; non-zero, so it neither trips the crashexit listener nor needs restarting.
@@ -103,10 +103,10 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept ; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
; in the image). Runs as root so logrotate can read/rotate the log. Exits ; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
; non-zero when the config is group/other-writable (logrotate would ignore it ; the config is group/other-writable (logrotate would ignore it silently);
; silently); autorestart surfaces BACKOFF on the Status page. ; autorestart surfaces BACKOFF on the Status page.
[program:logrotate] [program:logrotate]
command=/usr/local/bin/logrotate-loop.sh command=/usr/local/bin/logrotate-loop.sh
priority=400 priority=400
+13 -14
View File
@@ -1,18 +1,17 @@
# Example host Apache vhost for the SelfPost panel (spec 10.3, default # Example host Apache vhost for the SelfPost panel (default scenario). Install
# scenario). Install on the host running Apache — NOT inside the SelfPost # on the host running Apache — NOT inside the SelfPost container. Requires
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled # mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
# (`a2enmod ssl proxy proxy_http`).
# #
# Certificates: obtain with the Apache certbot plugin against THIS vhost # Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place # (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a # add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# :80 -> :443 redirect vhost, and renews in the background via its own timer. # redirect vhost, and renews in the background via its own timer. The resulting
# The resulting PEM files land at # PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host # /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
# — bind-mount that directory read-only into the SelfPost container (see # bind-mount that directory read-only into the SelfPost container (see
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix # ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
# can present the very same certificate on 465/587. One certificate, two # can present the very same certificate on 465/587. One certificate, two
# consumers (spec 10 p.4) — no separate cert just for the panel. # consumers — no separate cert just for the panel.
<VirtualHost *:443> <VirtualHost *:443>
ServerName mail.example.com ServerName mail.example.com
@@ -26,9 +25,9 @@
ProxyPass "/" "http://127.0.0.1:8080/" ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/" ProxyPassReverse "/" "http://127.0.0.1:8080/"
# The panel is plain HTTP behind the proxy; it never terminates TLS # The panel is plain HTTP behind the proxy; it never terminates TLS itself.
# itself (spec 10 p.2). Cookies are still marked Secure by the panel # Cookies are still marked Secure by the panel because the browser only ever
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost. # sees this HTTPS vhost.
</VirtualHost> </VirtualHost>
<VirtualHost *:80> <VirtualHost *:80>
+5 -5
View File
@@ -1,10 +1,10 @@
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully # Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
# automatic ACME with no separate certbot container. Caddy writes certificates # with no separate certbot container. Caddy writes certificates as plain PEM
# as plain PEM under its own data directory, which this fragment bind-mounts # under its own data directory, which this fragment bind-mounts to the host so
# to the host so SelfPost can read the same files. # SelfPost can read the same files.
# #
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes # NOTE: Caddy's on-disk cert path includes
# the ACME CA's name as a path segment, e.g. # the ACME CA's name as a path segment, e.g.
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt # <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
# This has been stable across recent Caddy releases but is an internal # This has been stable across recent Caddy releases but is an internal
+32 -33
View File
@@ -1,12 +1,12 @@
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5). # SelfPost — default deployment, reverse-proxy = Apache.
# #
# This file only runs SelfPost itself. Apache is assumed to already be # This file only runs SelfPost itself. Apache is assumed to already be installed
# installed on the HOST (the target audience for this project typically runs # on the HOST (the target audience for this project typically runs Apache there
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel; # already) and reverse-proxies HTTPS for the panel; it is not containerised
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready # here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
# vhost fragment, and docs/guide.md "Reverse proxy" for the # docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
# certbot steps that produce the PEM files this compose file mounts. # this compose file mounts.
# #
# Usage: # Usage:
# 1. Copy this file (and .env.example as .env) next to your own ./data and # 1. Copy this file (and .env.example as .env) next to your own ./data and
@@ -15,10 +15,9 @@
# TLS paths are fixed in this file to match that bind mount, not .env. # TLS paths are fixed in this file to match that bind mount, not .env.
# 3. docker compose up -d # 3. docker compose up -d
# #
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup # The image tag below is FIXED on purpose: backup restore compares the manifest
# restore compares the manifest version against the running binary's version, # version against the running binary's version, so ":latest" would make that
# so ":latest" would make that check meaningless. Bump the tag deliberately # check meaningless. Bump the tag deliberately when you want to upgrade.
# when you want to upgrade.
services: services:
selfpost: selfpost:
@@ -27,11 +26,11 @@ services:
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
# Path Postfix reads inside the container — matches the certs bind mount # Path Postfix reads inside the container — matches the certs bind mount
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2). # below. Point these at your reverse-proxy's PEM output.
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
# Set to true to also publish RFC 6409 submission (587/STARTTLS) # Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
# alongside the primary 465/smtps listener (spec 5). # the primary 465/smtps listener.
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}" INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}" DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
@@ -41,14 +40,14 @@ services:
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app # Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
# limits (level 2) are configured later from the panel itself. # configured later from the panel itself.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}" RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# How long the send log keeps rows before the background sweep deletes # How long the send log keeps rows before the background sweep deletes
# them (spec 7.3, 9) — the main driver of /data growth over time. # them — the main driver of /data growth over time.
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
# Sliding idle timeout for the panel login session, in days (spec 7.6.6). # Sliding idle timeout for the panel login session, in days.
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they # Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
# report what the internet sees rather than what this host's own stub # report what the internet sees rather than what this host's own stub
@@ -59,27 +58,27 @@ services:
# client spoof its rate-limit key. # client spoof its rate-limit key.
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}" TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
volumes: volumes:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
# sasldb2, sender map, setup token. Back this up (panel button or the # sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly. # selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files # here too (./data/log, rotated daily, 14 files kept) so the delivery
# kept) so the delivery lines that resolve a queued send-log row survive # lines that resolve a queued send-log row survive a container recreate;
# a container recreate; it is diagnostics and is left out of backups. # it is diagnostics and is left out of backups.
- ./data:/data - ./data:/data
# Read-only project root for full backups: docker-compose.yml, .env, and # Read-only project root for full backups: docker-compose.yml, .env, and
# certs/ are archived alongside /data (architecture.md § Persistence). # certs/ are archived alongside /data (architecture.md § Persistence).
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them # Read-only: SelfPost only ever reads certificates, never manages them.
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot # Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
# writes PEM files, e.g. /etc/letsencrypt on the host. # /etc/letsencrypt on the host.
- ./certs:/etc/postfix/tls:ro - ./certs:/etc/postfix/tls:ro
ports: ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only # published directly — mail traffic bypasses Apache entirely, it only ever
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself # proxies the panel's HTTP(S). The panel itself (8080) is intentionally
# (8080) is intentionally NOT published here: Apache reaches it over # NOT published here: Apache reaches it over the host network at
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the # 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
# panel is never directly reachable from the internet without TLS. # reachable from the internet without TLS.
- "465:465" - "465:465"
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports". # when the variable is true — see docs/guide.md "Published ports".
@@ -88,7 +87,7 @@ services:
# when the variable is true. # when the variable is true.
- "25:25" - "25:25"
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as # Hardening. SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions # root very briefly to fix /data ownership and normalise permissions
# under the shared `selfpost` group (see build/entrypoint.sh) before # under the shared `selfpost` group (see build/entrypoint.sh) before
# supervisord drops the panel to an unprivileged user — so this cannot be # supervisord drops the panel to an unprivileged user — so this cannot be
+6 -6
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host # Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
# exactly like the Apache+certbot scenario — nginx and certbot are close # Apache+certbot scenario — nginx and certbot are close cousins here, both
# cousins here, both writing/reading plain PEM under /etc/letsencrypt. # writing/reading plain PEM under /etc/letsencrypt.
# #
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it # This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
# adds an nginx + certbot pair and removes the panel's host port publish (nginx # adds an nginx + certbot pair and removes the panel's host port publish (nginx
@@ -29,9 +29,9 @@ services:
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro - .:/selfpost-deploy:ro
# Same host directory certbot below writes into — plain bind mount, # Same host directory certbot below writes into — plain bind mount, no
# no named volume, so the PEM files are as directly inspectable as in # named volume, so the PEM files are as directly inspectable as in the
# the Apache scenario (spec 10.3). # Apache scenario.
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro - ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
nginx: nginx:
+3 -3
View File
@@ -1,6 +1,6 @@
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel # nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
# over the compose network; mail (465/587) is published directly by the # compose network; mail (465/587) is published directly by the selfpost
# selfpost container and never touches nginx. # container and never touches nginx.
server { server {
listen 80; listen 80;
+3 -3
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain # Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost # everything (cert + key) lives bundled inside acme.json. SelfPost needs
# needs separate PEM files for Postfix, so an extraction step is required; see # separate PEM files for Postfix, so an extraction step is required; see
# extract-cert.sh in this directory and the cron/systemd-timer note below. # extract-cert.sh in this directory and the cron/systemd-timer note below.
# #
# Merge with the base file — run this from the deploy/ directory (Compose # Merge with the base file — run this from the deploy/ directory (Compose
+5 -5
View File
@@ -1,9 +1,9 @@
#!/bin/sh #!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json # Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction # Traefik keeps its certificates there, so a PEM extraction step is required.
# step is required"). Run this on the host, after Traefik has issued or # Run this on the host, after Traefik has issued or renewed the certificate, and
# renewed the certificate, and again on a schedule (cron/systemd timer) since # again on a schedule (cron/systemd timer) since acme.json is not itself watched
# acme.json is not itself watched 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>
set -eu set -eu
+56
View File
@@ -311,3 +311,59 @@ SelfPost — зрелый, хорошо структурированный пр
| 29 | Accessibility help drawer (§10.1) | Sonnet | 15 мин | | 29 | Accessibility help drawer (§10.1) | Sonnet | 15 мин |
| 30 | `io.LimitReader` для postqueue (§6.3) | Sonnet | 10 мин | | 30 | `io.LimitReader` для postqueue (§6.3) | Sonnet | 10 мин |
| 31 | Extractить `internal/configsafe` (§5.6) | Sonnet | 30 мин | | 31 | Extractить `internal/configsafe` (§5.6) | Sonnet | 30 мин |
---
## Статус реализации
Обновлено 2026-08-19 по факту проверки дерева (`main`).
| # | Задача | Статус |
|---|--------|--------|
| 1 | CSS для DMARC-страниц | сделано |
| 2 | SPF CIDR | не требовалось — `coversAny()` уже разбирает CIDR |
| 3 | `io.LimitReader` в DMARC ingest/parse | сделано |
| 4 | Вытеснение bucket'ов | сделано; плюс потолок роста карты заблокированных |
| 5 | Логирование fail-open auth IP | не требовалось — лог уже был |
| 6 | Мёртвый код в demotion guard | сделано |
| 7 | Flash-сообщения в настройках | сделано |
| 8 | `.check-row` | сделано иначе: класс определён в `panel.css` |
| 9 | `internal/atomicfile` | сделано |
| 10 | `internal/supervisor` | сделано |
| 11 | Объединение rate-limit парсеров | сделано; обёртки-заглушки удалены |
| 12 | Plan-файлы / CHANGELOG | сделано |
| 13 | `schema-migrations.md` | сделано: заголовок `v10`, битые ссылки убраны |
| 14 | Ссылки на spec в build/deploy | сделано: `build/`, `deploy/`, миграции, workflow |
| 15 | `dmarc-ingest` symlink | не требовалось — уже описан в `architecture.md` |
| 16 | Ссылки в roadmap | сделано |
| 17 | Типизированные template data structs | **не сделано** |
| 18 | Миграция на `log/slog` | **не сделано** |
| 19 | Build tags для `machine.go` | отклонено, см. ниже |
| 20 | Guide URL → `{{.SourceURL}}` | сделано |
| 21 | Тесты DMARC service layer | **не сделано** |
| 22 | Тесты хендлеров | **не сделано** |
| 23 | postfix/dmarc injection cases | сделано |
| 24 | `validate_email_test` | сделано |
| 25 | Тесты error pages / битого бэкапа | **не сделано** |
| 26 | CSP `unsafe-inline` | не требовалось — директивы в CSP нет |
| 27 | bcrypt cost 12 | сделано, включая setup-аккаунт (`auth.BcryptCost`) |
| 28 | Re-auth перед бэкапом | **не сделано** |
| 29 | Accessibility help drawer | сделано |
| 30 | Ограничение `postqueue -p` | сделано: таймаут 5 с + лимит вывода 4 МиБ |
| 31 | `internal/configsafe` | сделано |
**Отклонено с обоснованием.** §7.3 (задача 19): чтение `/proc` параметризовано
корнем каталога и не содержит платформенного кода, его тесты разбирают
подставные деревья и проходят на любой ОС, а отсутствие `/proc` уже
обрабатывается штатно (`TestMachineSamplerWithoutProc`). `//go:build linux`
не даёт ни выигрыша при сборке, ни в рантайме, но убирает эти тесты на машине
разработчика.
**Открытый вопрос.** §4.4: решение «добавить per-session CSRF-токен» записано,
но задачи под него в таблицах фаз нет, и токенов в коде нет. Нужно либо
завести задачу, либо вернуть §4.4 в статус осознанного компромисса.
**Вне фазовых таблиц** (в план не переносились и не делались): §5.5, §6.1,
§6.2, §6.4, §7.4, §8.6, §10.3, §10.4, §10.5, а также §9.3–9.4 сверх задач
23–25.
+12 -11
View File
@@ -70,13 +70,14 @@ replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct **Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 1.x images. for operators upgrading from older 1.x images.
Feature plans that introduced schema work: [inbound-relay](plans/inbound-relay.md), The feature plans that introduced schema work (`inbound-relay`,
[domain-stats-auto-ratelimit](plans/domain-stats-auto-ratelimit.md), `domain-stats-auto-ratelimit`, `dmarc-reports`) were deleted once they
[dmarc-reports](plans/dmarc-reports.md). shipped; what each migration was for is the table above, and the release it
came with is in [CHANGELOG.md](../CHANGELOG.md).
--- ---
## Schema after head (v9) ## Schema after head (v10)
Tables present in a fully migrated database: Tables present in a fully migrated database:
@@ -98,7 +99,7 @@ Tables present in a fully migrated database:
| `dmarc_reports` | 0008 | Parsed aggregate report summaries | | `dmarc_reports` | 0008 | Parsed aggregate report summaries |
| `dmarc_report_records` | 0008 | Per-source rows inside a report | | `dmarc_report_records` | 0008 | Per-source rows inside a report |
**Not present after v9:** `admin` (dropped in 0005). **Not present after v10:** `admin` (dropped in 0005).
--- ---
@@ -174,19 +175,19 @@ git; fresh 2.x installs only run the baseline plus post-2.x files.
Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x
on its own — only bundled with another breaking change or an explicit major. on its own — only bundled with another breaking change or an explicit major.
**Goal:** embed one baseline SQL file equal to the v9 schema (plus any later 1.x **Goal:** embed one baseline SQL file equal to the schema at whatever the 1.x
migrations if 2.x is cut later), instead of the full 1.x chain. Fresh 2.x chain head is when the squash ships (`v10` today), instead of the full 1.x
`/data` directories skip create-then-drop `admin`. chain. Fresh 2.x `/data` directories skip create-then-drop `admin`.
**Upgrade gate (required when squash ships):** **Upgrade gate (required when squash ships):**
| `user_version` | 2.x behaviour | | `user_version` | 2.x behaviour |
|---|---| |---|---|
| `0` (empty DB) | Apply baseline; set `user_version` to new chain head | | `0` (empty DB) | Apply baseline; set `user_version` to new chain head |
| `>= 9` (fully migrated 1.x) | Skip; schema already matches baseline | | `>= N` (fully migrated 1.x) | Skip; schema already matches baseline |
| `1`…`8` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x | | `1`…`N-1` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x |
Adjust the `>= N` and `1`…`N-1` thresholds to the chain head at cut time. `N` is the chain head at cut time (`10` today).
**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that **Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that
2.x will not open an unfinished 1.x database. 2.x will not open an unfinished 1.x database.
+28
View File
@@ -0,0 +1,28 @@
// Package configsafe holds the defence-in-depth check that every generated
// configuration file shares: a value about to be written into a table line must
// be non-empty and must not carry a character that could end the field, end the
// line, or otherwise change what the file means (security.md).
//
// The forbidden set stays with the caller rather than living here, because what
// breaks a line differs per format — a comma separates values in a Postfix map,
// a colon separates fields in an OpenDKIM table — and the set belongs next to
// the writer it protects, where a reviewer of that writer can see it.
package configsafe
import (
"fmt"
"strings"
)
// Token reports whether value is safe to interpolate into a config line. what
// names the field for the error message ("domain", "login", …) and forbidden
// lists the characters the format cannot carry.
func Token(what, value, forbidden string) error {
if value == "" {
return fmt.Errorf("empty %s", what)
}
if strings.ContainsAny(value, forbidden) {
return fmt.Errorf("unsafe character in %s %q", what, value)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package configsafe
import "testing"
// The check is the last line of defence before a value reaches a config file,
// so it must reject both halves of the problem: an empty value that would write
// a malformed line, and any character from the caller's forbidden set.
func TestToken(t *testing.T) {
if err := Token("domain", "example.com", " \t\r\n,\\"); err != nil {
t.Fatalf("clean value rejected: %v", err)
}
if err := Token("domain", "", " "); err == nil {
t.Fatal("empty value accepted")
}
for _, bad := range []string{"a b", "a\tb", "a\rb", "a\nb", "a,b", "a\\b"} {
if err := Token("domain", bad, " \t\r\n,\\"); err == nil {
t.Errorf("Token(%q) = nil, want error", bad)
}
}
// A character outside the caller's set is that format's business, not this
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash.
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
t.Errorf("character outside the forbidden set rejected: %v", err)
}
}
+12 -6
View File
@@ -10,6 +10,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/supervisor" "github.com/mixeme/selfpost/internal/supervisor"
) )
@@ -202,18 +203,23 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
return []byte(kt.String()), []byte(st.String()), nil return []byte(kt.String()), []byte(st.String()), nil
} }
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
// value: whitespace and the newline that would end the line, and the colon and
// path separators that carry meaning inside a table entry.
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
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors // single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (security.md); this is defence in depth // to a similar set before they reach here (security.md); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field // against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (security.md). // separator through into a config file (security.md).
func assertConfigSafe(domainName, selector string) error { func assertConfigSafe(domainName, selector string) error {
for _, v := range []string{domainName, selector} { for _, f := range []struct{ what, value string }{
if v == "" { {"domain", domainName},
return fmt.Errorf("opendkim: empty domain or selector") {"selector", selector},
} } {
if strings.ContainsAny(v, " \t\r\n:/\\") { if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
return fmt.Errorf("opendkim: unsafe character in %q", v) return fmt.Errorf("opendkim: %w", err)
} }
} }
return nil return nil
+9 -5
View File
@@ -6,6 +6,8 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"github.com/mixeme/selfpost/internal/configsafe"
) )
// InboundRoute is one inbound domain's Postfix map material: the relay // InboundRoute is one inbound domain's Postfix map material: the relay
@@ -115,13 +117,15 @@ func assertInboundRouteSafe(r InboundRoute) error {
return nil return nil
} }
// texthashForbidden is what may never appear in a texthash line: whitespace and
// the newline that would end it, the comma that separates values, and the
// backslash.
const texthashForbidden = " \t\r\n,\\"
// assertMapToken rejects values that could break out of a texthash line. // assertMapToken rejects values that could break out of a texthash line.
func assertMapToken(v, what string) error { func assertMapToken(v, what string) error {
if v == "" { if err := configsafe.Token(what, v, texthashForbidden); err != nil {
return fmt.Errorf("postfix: empty %s", what) return fmt.Errorf("postfix: %w", err)
}
if strings.ContainsAny(v, " \t\r\n,\\") {
return fmt.Errorf("postfix: unsafe character in %s %q", what, v)
} }
return nil return nil
} }
+14 -7
View File
@@ -11,6 +11,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/supervisor" "github.com/mixeme/selfpost/internal/supervisor"
) )
@@ -123,6 +124,15 @@ func appendUnique(list []string, v string) []string {
return append(list, v) return append(list, v)
} }
// What may never reach a sender-map line: whitespace and the newline that would
// end it, the comma that separates values, the colon a lookup key cannot carry,
// and the backslash. A login additionally may not carry "@" — that would make it
// look like an address.
const (
senderMapAddressForbidden = " \t\r\n,:\\"
senderMapLoginForbidden = " \t\r\n,:@\\"
)
// assertMapSafe rejects any address/login value that could break out of a single // assertMapSafe rejects any address/login value that could break out of a single
// map line or inject a directive. Addresses are validated to a strict whitelist // map line or inject a directive. Addresses are validated to a strict whitelist
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one // (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
@@ -130,14 +140,11 @@ func appendUnique(list []string, v string) []string {
// letting whitespace, a newline or a comma (the value separator) through into // letting whitespace, a newline or a comma (the value separator) through into
// the file (security.md). // the file (security.md).
func assertMapSafe(address, login string) error { func assertMapSafe(address, login string) error {
if address == "" || login == "" { if err := configsafe.Token("address", address, senderMapAddressForbidden); err != nil {
return fmt.Errorf("postfix: empty address or login") return fmt.Errorf("postfix: %w", err)
} }
if strings.ContainsAny(address, " \t\r\n,:\\") { if err := configsafe.Token("login", login, senderMapLoginForbidden); err != nil {
return fmt.Errorf("postfix: unsafe character in address %q", address) return fmt.Errorf("postfix: %w", err)
}
if strings.ContainsAny(login, " \t\r\n,:@\\") {
return fmt.Errorf("postfix: unsafe character in login %q", login)
} }
return nil return nil
} }
+40 -3
View File
@@ -1,6 +1,7 @@
package postfix package postfix
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os/exec" "os/exec"
@@ -11,6 +12,35 @@ import (
const postqueueTimeout = 5 * time.Second const postqueueTimeout = 5 * time.Second
// maxQueueOutput bounds how much of `postqueue -p` the panel keeps. A deferred
// queue with a very large backlog prints one paragraph per message, and the
// whole listing is held in memory and then rendered into a page — so the read
// is capped and the remainder reported as truncated rather than being allowed
// to size the panel's memory by the size of the queue.
const maxQueueOutput = 4 << 20
// limitWriter keeps the first remaining bytes written to it and drops the rest,
// recording that it did. Writes always report full consumption so postqueue is
// never killed by a short write; the extra output is simply discarded.
type limitWriter struct {
buf bytes.Buffer
remaining int
truncated bool
}
func (l *limitWriter) Write(p []byte) (int, error) {
keep := len(p)
if keep > l.remaining {
keep = l.remaining
l.truncated = true
}
if keep > 0 {
l.buf.Write(p[:keep])
l.remaining -= keep
}
return len(p), nil
}
// Queue returns Postfix's own human-readable mail-queue listing // Queue returns Postfix's own human-readable mail-queue listing
// (architecture.md § Panel HTTP surface): active, deferred and held messages, // (architecture.md § Panel HTTP surface): active, deferred and held messages,
// exactly as an administrator would see via the CLI. The command takes a // exactly as an administrator would see via the CLI. The command takes a
@@ -21,14 +51,21 @@ func Queue() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout) ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "postqueue", "-p") cmd := exec.CommandContext(ctx, "postqueue", "-p")
out, err := cmd.CombinedOutput() out := &limitWriter{remaining: maxQueueOutput}
cmd.Stdout = out
cmd.Stderr = out
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("postqueue -p timed out after %s", postqueueTimeout) return "", fmt.Errorf("postqueue -p timed out after %s", postqueueTimeout)
} }
if err != nil { if err != nil {
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out))) return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(out.buf.String()))
} }
return string(out), nil text := out.buf.String()
if out.truncated {
text += fmt.Sprintf("\n[listing truncated at %d bytes]\n", maxQueueOutput)
}
return text, nil
} }
// QueueIDs returns the set of queue ids Postfix is still holding — everything // QueueIDs returns the set of queue ids Postfix is still holding — everything
+31
View File
@@ -41,3 +41,34 @@ func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
t.Errorf("got %v, want no ids", ids) t.Errorf("got %v, want no ids", ids)
} }
} }
// The queue listing is held in memory and rendered into a page, so a very large
// queue must not size the panel's allocation: the writer keeps the cap and
// reports the rest as dropped, while still accepting every write so postqueue
// itself is never cut short.
func TestLimitWriterCapsOutput(t *testing.T) {
w := &limitWriter{remaining: 8}
n, err := w.Write([]byte("12345"))
if n != 5 || err != nil {
t.Fatalf("first write = %d, %v", n, err)
}
if w.truncated {
t.Fatal("truncated before the cap was reached")
}
n, err = w.Write([]byte("67890"))
if n != 5 || err != nil {
t.Fatalf("second write = %d, %v", n, err)
}
if !w.truncated {
t.Fatal("write past the cap was not recorded as truncated")
}
if got := w.buf.String(); got != "12345678" {
t.Fatalf("kept %q, want the first 8 bytes", got)
}
if n, err := w.Write([]byte("more")); n != 4 || err != nil {
t.Fatalf("write after the cap = %d, %v (must still accept)", n, err)
}
if got := w.buf.String(); got != "12345678" {
t.Fatalf("buffer grew past the cap: %q", got)
}
}
+14 -14
View File
@@ -1,9 +1,9 @@
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole -- Initial SelfPost schema. One SQLite file under /data holds the whole panel
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A). -- state so a single directory backup/restore is sufficient.
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the -- Single administrator account. Exactly one row is allowed; the presence of
-- presence of that row is what marks primary setup as complete, which is why -- that row is what marks primary setup as complete, which is why the /setup
-- the /setup route disappears once it exists. -- route disappears once it exists.
CREATE TABLE admin ( CREATE TABLE admin (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,8 +17,8 @@ CREATE TABLE settings (
value TEXT NOT NULL value TEXT NOT NULL
); );
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves -- Sending domains managed through the panel. DKIM keys themselves live on disk
-- live on disk under /data; this row records the selector and metadata. -- under /data; this row records the selector and metadata.
CREATE TABLE domains ( CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@@ -26,8 +26,8 @@ CREATE TABLE domains (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
-- Applications bound to a domain (spec 4.1). address_mode is either the domain -- Applications bound to a domain. address_mode is either the domain wildcard or
-- wildcard or an explicit address list; the SASL login is globally unique. -- an explicit address list; the SASL login is globally unique.
CREATE TABLE applications ( CREATE TABLE applications (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
@@ -37,7 +37,7 @@ CREATE TABLE applications (
); );
-- Explicit sender addresses for applications in 'list' mode. Each address must -- Explicit sender addresses for applications in 'list' mode. Each address must
-- belong to the application's domain (validated in the panel, spec 7.6.2). -- belong to the application's domain (validated in the panel).
CREATE TABLE application_addresses ( CREATE TABLE application_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE, application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
@@ -45,8 +45,8 @@ CREATE TABLE application_addresses (
UNIQUE (application_id, address) UNIQUE (application_id, address)
); );
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the -- Structured send log. One row per (queue-id, recipient); the log-tailer
-- log-tailer advances status from queued to a final state. -- advances status from queued to a final state.
CREATE TABLE send_log ( CREATE TABLE send_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_id TEXT, queue_id TEXT,
@@ -63,8 +63,8 @@ CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
CREATE INDEX idx_send_log_domain ON send_log (domain); CREATE INDEX idx_send_log_domain ON send_log (domain);
CREATE INDEX idx_send_log_created_at ON send_log (created_at); CREATE INDEX idx_send_log_created_at ON send_log (created_at);
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP -- Differentiated rate limits per domain/application. Both the IP binding and
-- binding and the message limit are optional. -- the message limit are optional.
CREATE TABLE rate_limits ( CREATE TABLE rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')), scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
+5 -5
View File
@@ -1,8 +1,8 @@
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a -- Panel login sessions. Persisted so a login survives a container restart or
-- container restart or redeploy; only the SHA-256 of the session token is -- redeploy; only the SHA-256 of the session token is stored, never the token
-- stored, never the token itself, so a stolen database file cannot be used to -- itself, so a stolen database file cannot be used to sign in. expires_at
-- sign in. expires_at implements the sliding idle timeout: it is pushed -- implements the sliding idle timeout: it is pushed forward on activity rather
-- forward on activity rather than being fixed at creation time. -- than being fixed at creation time.
CREATE TABLE sessions ( CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
+8 -1
View File
@@ -15,6 +15,13 @@ const (
sessionCookiePrefixed = "__Host-" + sessionCookieBase sessionCookiePrefixed = "__Host-" + sessionCookieBase
) )
// BcryptCost is the work factor for every panel password hash — the setup
// account created here and the ones the settings and user pages write
// (security.md). bcrypt records the cost inside the hash, so raising it only
// affects passwords set from now on; existing hashes keep verifying at the
// cost they were written with.
const BcryptCost = 12
func (m *Module) sessionCookie() string { func (m *Module) sessionCookie() string {
if m.cfg.CookieSecure { if m.cfg.CookieSecure {
return sessionCookiePrefixed return sessionCookiePrefixed
@@ -219,7 +226,7 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin
return return
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil { if err != nil {
logf("panel: setup: hashing password failed: %v", err) logf("panel: setup: hashing password failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
+35 -8
View File
@@ -7,6 +7,16 @@ import (
const defaultMaxBuckets = 4096 const defaultMaxBuckets = 4096
// blockedBucketCeiling is how far past maxBuckets the map may grow while every
// bucket in it is blocked. Blocked buckets are normally protected from
// eviction (see evictNearest), but protecting them unconditionally would let
// an attacker with many source addresses grow the map without bound — each
// address only has to fail max times. Past the ceiling blocked buckets are
// evicted too, nearest expiry first: bounded memory wins over holding every
// block, and the addresses that lose their bucket have to spend max attempts
// again to get back to blocked.
const blockedBucketCeiling = 4
// rateLimiter is a simple fixed-window per-key counter used to throttle the // rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs. // setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct { type rateLimiter struct {
@@ -14,8 +24,9 @@ type rateLimiter struct {
window time.Duration window time.Duration
maxBuckets int maxBuckets int
mu sync.Mutex mu sync.Mutex
buckets map[string]*rlBucket buckets map[string]*rlBucket
lastOverflow time.Time
} }
type rlBucket struct { type rlBucket struct {
@@ -68,22 +79,38 @@ func (r *rateLimiter) makeRoom(now time.Time) {
return return
} }
r.sweep(now) r.sweep(now)
hardCap := r.maxBuckets * blockedBucketCeiling
for len(r.buckets) >= r.maxBuckets { for len(r.buckets) >= r.maxBuckets {
if !r.evictNearest() { if r.evictNearest(false) {
continue
}
// Nothing evictable is left: every remaining bucket is blocked. Hold
// them until the map reaches the hard ceiling, then start evicting
// blocked buckets as well so memory stays bounded.
if len(r.buckets) < hardCap {
break break
} }
if !r.evictNearest(true) {
break
}
if now.Sub(r.lastOverflow) >= r.window {
r.lastOverflow = now
logf("panel: login rate limiter holds %d blocked buckets (cap %d) — evicting blocked entries; "+
"this means many distinct client addresses are failing authentication", len(r.buckets), r.maxBuckets)
}
} }
} }
// evictNearest removes the bucket whose window ends soonest, skipping buckets // evictNearest removes the bucket whose window ends soonest and reports whether
// that are currently blocked (count >= max) so an attacker cannot flush active // it removed one. Blocked buckets (count >= max) are skipped unless evictBlocked
// rate-limit state by filling the map with new keys. // is set, so an attacker cannot flush active rate-limit state by filling the map
func (r *rateLimiter) evictNearest() bool { // with new keys.
func (r *rateLimiter) evictNearest(evictBlocked bool) bool {
var bestKey string var bestKey string
var bestEnds time.Time var bestEnds time.Time
first := true first := true
for k, b := range r.buckets { for k, b := range r.buckets {
if b.count >= r.max { if !evictBlocked && b.count >= r.max {
continue continue
} }
if first || b.windowEnds.Before(bestEnds) { if first || b.windowEnds.Before(bestEnds) {
+51
View File
@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -123,3 +124,53 @@ func TestRateLimiterCapsBucketCount(t *testing.T) {
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets)) t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
} }
} }
// A flood of new addresses must not be able to reset the limiter for an address
// that is already blocked: below the hard ceiling, blocked buckets survive
// eviction even though the map is over its soft cap.
func TestRateLimiterKeepsBlockedBucketsUnderPressure(t *testing.T) {
r := newRateLimiter(2, time.Minute)
r.maxBuckets = 3
blocked := []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"}
for _, key := range blocked {
for i := 0; i < 2; i++ {
if !r.Allow(key) {
t.Fatalf("%s refused before reaching the limit", key)
}
}
if r.Allow(key) {
t.Fatalf("%s was not blocked after 2 attempts", key)
}
}
// Fresh addresses arrive and find nothing evictable.
for i := 0; i < 10; i++ {
r.Allow(fmt.Sprintf("198.51.100.%d", 100+i))
}
for _, key := range blocked {
if r.Allow(key) {
t.Fatalf("%s lost its block after new addresses arrived", key)
}
}
}
// Protecting blocked buckets is bounded: past maxBuckets * blockedBucketCeiling
// they are evicted too, so an attacker cannot grow the map without limit by
// burning through source addresses.
func TestRateLimiterBoundsBlockedBuckets(t *testing.T) {
r := newRateLimiter(1, time.Minute)
r.maxBuckets = 2
hardCap := r.maxBuckets * blockedBucketCeiling
for i := 0; i < 100; i++ {
r.Allow(fmt.Sprintf("192.0.2.%d", i))
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buckets) > hardCap {
t.Fatalf("bucket count = %d, want at most the hard cap %d", len(r.buckets), hardCap)
}
}
@@ -98,14 +98,6 @@ func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
} }
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
return parseRateLimitForm(r, l1Max)
}
func parseAppRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
return parseRateLimitForm(r, l1Max)
}
func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) { func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return false, nil, fmt.Errorf("invalid form submission") return false, nil, fmt.Errorf("invalid form submission")
@@ -8,7 +8,9 @@ import (
"testing" "testing"
) )
func TestParseDomainRateLimitForm(t *testing.T) { // Domain and application limits are parsed by the same function; the form
// fields and the level-1 backstop are identical for both scopes.
func TestParseRateLimitForm(t *testing.T) {
t.Parallel() t.Parallel()
form := func(vals url.Values) *http.Request { form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode())) r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
@@ -16,7 +18,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
return r return r
} }
in, err := parseDomainRateLimitForm(form(url.Values{ in, err := parseRateLimitForm(form(url.Values{
"mode": {"manual"}, "mode": {"manual"},
"max_messages": {"50"}, "max_messages": {"50"},
"window_seconds": {"3600"}, "window_seconds": {"3600"},
@@ -25,44 +27,12 @@ func TestParseDomainRateLimitForm(t *testing.T) {
t.Fatalf("valid domain = %+v err=%v", in, err) t.Fatalf("valid domain = %+v err=%v", in, err)
} }
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100) in, err = parseRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
if err != nil || !in.clear { if err != nil || !in.clear {
t.Fatalf("empty max should clear: %+v err=%v", in, err) t.Fatalf("empty max should clear: %+v err=%v", in, err)
} }
_, err = parseDomainRateLimitForm(form(url.Values{ _, err = parseRateLimitForm(form(url.Values{
"mode": {"manual"},
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100)
if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err)
}
}
func TestParseAppRateLimitForm(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100)
if err != nil || in.maxMessages != 80 {
t.Fatalf("valid app limit = %+v err=%v", in, err)
}
in, err = parseAppRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
if err != nil || !in.clear {
t.Fatalf("empty max should clear: %+v err=%v", in, err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"}, "mode": {"manual"},
"max_messages": {"150"}, "max_messages": {"150"},
"window_seconds": {"3600"}, "window_seconds": {"3600"},
@@ -99,21 +69,21 @@ func TestParseAppAuthIPsForm(t *testing.T) {
} }
} }
func TestParseDomainRateLimitFormAuto(t *testing.T) { func TestParseRateLimitFormAuto(t *testing.T) {
t.Parallel() t.Parallel()
form := func(vals url.Values) *http.Request { form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode())) r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r return r
} }
in, err := parseDomainRateLimitForm(form(url.Values{ in, err := parseRateLimitForm(form(url.Values{
"mode": {"auto"}, "mode": {"auto"},
"auto_multiplier": {"2.5"}, "auto_multiplier": {"2.5"},
}), 100) }), 100)
if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 { if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 {
t.Fatalf("auto domain = %+v err=%v", in, err) t.Fatalf("auto domain = %+v err=%v", in, err)
} }
_, err = parseDomainRateLimitForm(form(url.Values{ _, err = parseRateLimitForm(form(url.Values{
"mode": {"auto"}, "mode": {"auto"},
"auto_multiplier": {"10"}, "auto_multiplier": {"10"},
}), 100) }), 100)
+5 -1
View File
@@ -1,3 +1,7 @@
package handlers package handlers
const bcryptCost = 12 import "github.com/mixeme/selfpost/internal/web/auth"
// bcryptCost is the panel-wide password work factor; see auth.BcryptCost for
// why it lives in one place.
const bcryptCost = auth.BcryptCost