audit 5: findings of the two reviews
- The e2e's numeric-settings test ran a container that passed every gate
and never ended: the good values are checked beside a bad one after
them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
kills anvil, a zero milter timeout smtpd) and one past an int, in
seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
reads into a queue at once and drops past 200 lines a second, saying
how many (security F1, F2; a stuck relay would hold OpenDKIM in
syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
what else exits the container; security.md the restart after five panel
exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
(Groups 104 998), so it did reach the supervisor socket before this
stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.
Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
-5
@@ -46,10 +46,17 @@ that will not start, is an exit supervisord counts as too quick: once
|
||||
each start it is STARTING.
|
||||
|
||||
`crashexit` event listener exits the container when a program of the mail path
|
||||
is FATAL, so Docker `restart: unless-stopped` starts it again - the same
|
||||
container, not a new one: the entrypoint clears what the last run left in
|
||||
`/run` (the milter sockets, the sender check's alive mark). `logrotate`'s
|
||||
FATAL (a config logrotate would ignore) stays on Server › Health instead.
|
||||
is FATAL - it crashed before its `startsecs` four times running (the start
|
||||
and `startretries=3`) - or has
|
||||
exited unexpectedly five times in ten minutes, however long each run lasted
|
||||
(an exit after `startsecs` is EXITED and restarted, never FATAL; `2.6.0`), so
|
||||
Docker `restart: unless-stopped` starts it again - the same container, not a
|
||||
new one: the entrypoint clears what the last run left in `/run` (the milter
|
||||
sockets, the sender check's alive mark). Besides a stop, supervisord itself
|
||||
dying, or the entrypoint refusing a setting, nothing else exits the
|
||||
container. `logrotate`'s FATAL (a config logrotate would ignore),
|
||||
`postfix-reload`'s (reported to the page that asked), `cert-reload`'s and
|
||||
`syslog-relay`'s stay on Server › Health instead.
|
||||
The panel runs `postfix-reload` through supervisord's XML-RPC interface and
|
||||
waits for its exit status (`internal/supervisor`, `RunOnce`); the program
|
||||
rests EXITED after a run.
|
||||
@@ -1110,7 +1117,9 @@ unsupported rather than as a missing doc:
|
||||
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
|
||||
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose).
|
||||
full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR`
|
||||
(`/data/antispam` — the filter lists and the filter's key, which the spam
|
||||
filter's container mounts).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
|
||||
@@ -1418,31 +1418,99 @@ documented in security.md.
|
||||
|
||||
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
|
||||
|
||||
- [ ] **Supervisor socket**: a group of its own (`supervisor`), panel only;
|
||||
- [x] **Supervisor socket**: a group of its own (`supervisor`), panel only;
|
||||
`postfix`/`opendkim` keep `selfpost` for the file paths. — **Opus**
|
||||
- [ ] **OpenDKIM**: `Syslog yes` (supervisord captures it) or `Logfile`;
|
||||
*Done 2026-10-15:* `groupadd --system supervisor`, the panel its one
|
||||
member; `[unix_http_server] chown=root:supervisor`. e2e: every program
|
||||
RUNNING, the panel still reloads OpenDKIM and Postfix through it.
|
||||
- [x] **OpenDKIM**: `Syslog yes` (supervisord captures it) or `Logfile`;
|
||||
`Canonicalization relaxed/simple`; `OversignHeaders From`. e2e: DKIM still
|
||||
verifies; a message with two `From:` is refused before signing anyway.
|
||||
— **Opus**
|
||||
- [ ] **Postfix**: `smtpd_tls_protocols = >=TLSv1.2`, `smtpd_tls_mandatory_protocols`
|
||||
*Done 2026-10-15,* read in OpenDKIM 2.11.0~beta2's source first: neither
|
||||
way worked - syslog(3) writes to `/dev/log`, which nobody served in the
|
||||
container, and there is no `Logfile` option. `internal/syslogrelay`
|
||||
(`panel -syslog-relay=/run/syslog/log`, program `syslog-relay` before
|
||||
OpenDKIM, `/dev/log` a link to it from the entrypoint) passes the lines
|
||||
to stdout, severity error and worse marked. `Syslog yes`,
|
||||
`Canonicalization relaxed/simple`, `OversignHeaders From` (RFC 6376
|
||||
§ 3.4, § 8.15). e2e: OpenDKIM's start line in the container log; the
|
||||
DKIM-verify steps pass. The two-`From:` refusal is the sender check's
|
||||
(2.4.0, 2.5.2), unchanged.
|
||||
- [x] **Postfix**: `smtpd_tls_protocols = >=TLSv1.2`, `smtpd_tls_mandatory_protocols`
|
||||
likewise, `disable_vrfy_command = yes`, `smtpd_banner = $myhostname ESMTP`;
|
||||
integer validation of `RATE_LIMIT_*`, `*_SIZE_LIMIT`, `MILTER_*_TIMEOUT` at
|
||||
the top of `postfix-config.sh` with a clear exit. — **Opus**
|
||||
- [ ] **logrotate state under `/data/log/.logrotate.status`** (`-s`), group-
|
||||
*Done 2026-10-15:* the four settings (proto/postconf.proto: `>=version`
|
||||
since 3.6); `need_number` / `need_time` (a whole number, a time value
|
||||
with s m h d w) for the six counts and sizes and the three milter
|
||||
timeouts. e2e: `postconf -h` reads them back; `TestNumericSettingsGate`
|
||||
starts the image with three bad values and one good set.
|
||||
- [x] **logrotate state under `/data/log/.logrotate.status`** (`-s`), group-
|
||||
readable as the directory is. — **Opus**
|
||||
- [ ] **entrypoint**: normalise the three DMARC maps with the other five;
|
||||
*Done 2026-10-15:* `logrotate -s /data/log/.logrotate.status`
|
||||
(logrotate.8.in: "-s, --state statefile"); the entrypoint's
|
||||
`find /data/log -type f -exec chmod 0640` covers it. e2e: the file is
|
||||
there after the start.
|
||||
- [x] **entrypoint**: normalise the three DMARC maps with the other five;
|
||||
remove the dead `set-permissions` branch. — **Opus**
|
||||
- [ ] **`extract-cert.sh`**: `set -o pipefail` under `bash`, check both outputs
|
||||
*Done before this stage:* the maps since the review of stage 3 (every
|
||||
file beside `queue/` in `/data/postfix`, H1 there), the branch since
|
||||
stage 2c (assumptions C-P34).
|
||||
- [x] **`extract-cert.sh`**: `set -o pipefail` under `bash`, check both outputs
|
||||
are non-empty PEM before moving them into place, create `0600` first.
|
||||
— **Opus**
|
||||
- [ ] **`crashexit`**: subscribe to `PROCESS_STATE_EXITED` with a restart-count
|
||||
*Done 2026-10-15:* bash, `set -euo pipefail`, `umask 077`; jq's
|
||||
`first(...) // empty` per field, refused when empty or not PEM; `mktemp`
|
||||
beside the target, `chmod 0640`, `mv`; the files that were there stay on
|
||||
any refusal. Run on the test server with a stand-in jq (the host has
|
||||
none): a good pair, a missing domain, a non-PEM field. Executable in
|
||||
git.
|
||||
- [x] **`crashexit`**: subscribe to `PROCESS_STATE_EXITED` with a restart-count
|
||||
threshold, or raise `startsecs` so a crash after 1 s still reaches FATAL;
|
||||
say in architecture.md what actually exits the container. — **Opus**
|
||||
- [ ] **`envdoc_test` made real**: parse `envDefault|envInt|os.Getenv("…")` out of
|
||||
*Done 2026-10-15:* `PROCESS_STATE_FATAL,PROCESS_STATE_EXITED`; five
|
||||
`expected:0` exits of one program in 600 s shut down, as FATAL does;
|
||||
`syslog-relay` joins the spared. `build/crashexit_test.go` runs the
|
||||
script under python with `CRASHEXIT_DRYRUN`. architecture.md says what
|
||||
exits the container.
|
||||
- [x] **`envdoc_test` made real**: parse `envDefault|envInt|os.Getenv("…")` out of
|
||||
`cmd/panel/*.go` and `${VAR` out of `build/*.sh`, parse the key tables out
|
||||
of guide.md and architecture.md, compare. — **Opus**
|
||||
- [ ] Docs: architecture § Image and processes, § Configuration; security.md
|
||||
*Done 2026-10-15:* read from cmd/panel and internal (tests aside) and
|
||||
build/*.sh (`${VAR:-…}`), documented in guide.md § Environment variables
|
||||
and architecture.md's internal list; both ways. It found `ANTISPAM_DIR`
|
||||
documented nowhere (added).
|
||||
- [x] Docs: architecture § Image and processes, § Configuration; security.md
|
||||
§ Process. — **Sonnet**
|
||||
*Done 2026-10-15* with the steps: the programs table (`syslog-relay`),
|
||||
what exits the container, `ANTISPAM_DIR`; security.md § Output and
|
||||
process (the socket's group).
|
||||
|
||||
*Changed by the reviews of stage 5 (2026-10-15; technical and security):*
|
||||
the e2e's numeric-settings test ran a container that passed every gate and
|
||||
never ended (the first run of the stage's e2e hung on it): the good values
|
||||
are now checked beside a bad one after them, and the runs are named,
|
||||
bounded to a minute and removed. The gates refuse what is under a
|
||||
parameter's minimum (a zero window kills anvil, a zero milter timeout
|
||||
smtpd and cleanup - src/anvil/anvil.c, src/smtpd/smtpd.c) and what passes an
|
||||
int, in seconds for a time value (src/global/conv_time.c reads one unit and
|
||||
refuses an overflow). OpenDKIM waits, five seconds at most, for the relay's
|
||||
socket. The relay marks its lines `syslog: `, writes control characters
|
||||
out (a forged second line), reads at once into a queue and drops past 200
|
||||
lines a second or a full queue, saying how many - a relay that stopped
|
||||
reading would have held OpenDKIM in syslog(3). `smtp_tls_mandatory_protocols`
|
||||
too. extract-cert.sh keeps the old files' group (a cron run as root handed
|
||||
the key to root's group) and sets its trap before mktemp. `cert-reload`
|
||||
joins the spared programs. security.md: the restart after five panel exits,
|
||||
and the relay's socket. Measured, against the technical review's guess:
|
||||
smtpd runs with the `selfpost` group among its groups (Uid 101, Groups
|
||||
104 998 on the test server), so before this stage it did reach the
|
||||
supervisor socket. Accepted, with the reason: the logrotate state file in
|
||||
the `postfix` user's directory - that user can stop rotation through the
|
||||
log it owns anyway; envdoc_test reads `${VAR:-}` and the Go helpers, not
|
||||
bare `$VAR` or python (none today); the 600 s pruning in crashexit is not
|
||||
tested (the count and the spared are).
|
||||
|
||||
### Stage 6 — documentation and roadmap sweep (no product code, no cut)
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`, 4 `2.5.4`; next stage 5 → `2.6.0`) | 68/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`, 4 `2.5.4`; next stage 5 → `2.6.0`) | 77/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||
|
||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||
|
||||
+13
-1
@@ -572,7 +572,10 @@ deferred item from the roadmap.
|
||||
off by whoever can make the panel slow. Supervisord starts a panel that
|
||||
died again at once; one that dies within a second of starting is tried
|
||||
four times in a few seconds, and then SelfPost's `crashexit` listener takes
|
||||
the container down (supervisor 4.2.5, `startretries=3`), so the outage is
|
||||
the container down (supervisor 4.2.5, `startretries=3`); since `2.6.0` it
|
||||
does so too when the panel exits unexpectedly five times in ten minutes,
|
||||
however long each run lasted - so five malformed packets on a milter
|
||||
socket (the next item) restart the whole container. The outage is
|
||||
seconds or the whole instance; Server › Health counts the
|
||||
downtime and the mail deferred for it. Trigger to revisit: those numbers
|
||||
not staying at nothing - then the check moves into a process of its own
|
||||
@@ -585,6 +588,15 @@ deferred item from the roadmap.
|
||||
of the restart defer mail instead of passing it unjournalled (technical
|
||||
review, 2026-10-12). Trigger to revisit: anything untrusted able to open
|
||||
a Unix socket in the container.
|
||||
- **The container's `/dev/log` takes datagrams from any user in it** (since
|
||||
`2.6.0`, for OpenDKIM's log): what is written there reaches the container
|
||||
log, each line marked `syslog: `, control characters written out, at most
|
||||
200 lines a second (the rest counted and dropped). A user who can write it
|
||||
can add lines to `docker logs` under that mark and no other; the panel
|
||||
reads only `mail.log`. logrotate's state file is under `/data/log`, the
|
||||
`postfix` user's directory: that user could stop rotation through it -
|
||||
as through the log itself, which it owns. Trigger to revisit: anything
|
||||
untrusted able to run in the container.
|
||||
- **When the hostname is itself a sending domain,** the server's mail is
|
||||
signed with that domain's key. Whoever may export the domain has the key,
|
||||
and its applications may send as `postmaster@<hostname>` by right. The
|
||||
|
||||
Reference in New Issue
Block a user