audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s

All 2026-10-15; each written beside the code or text it settles.

- A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix
  3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only):
  Debian does not install it set-gid, so it is no use to dmarc-ingest
  (cmd/panel/dmarc_ingest.go).
- C-X1: OpenSSL 3.0.22 in the image prints the same public key from
  `x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1
  P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys.
- C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland
  proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client
  and the host's own public IPv4 address are logged as themselves; the
  host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every
  client is the gateway: one level-1 budget, an application IP list cannot
  name them, rspamd sees the gateway. Guide paragraph under level 1, notes
  at the milter and allow-list, comments at the anvil setup and authip.go,
  and a Security entry in the CHANGELOG (an allow-list naming the gateway
  would admit every IPv6 client).
- D-guide:61: a published port nothing listens on (a throwaway container,
  -p 20999:25) is refused from outside over IPv4 and from the host's public
  IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6
  goes through too (not probed over IPv6 for this).
- C-M8: the live installation has received one DMARC report, of one
  record: nothing to set maxReportRecords by yet - said at the constant.
- D-S7, D-arch:516, D-arch:532: a new database filled through the store to
  120 days at 1 900 messages a day (228 000 messages, 1.37 million lines,
  464 MB) on the test server (2 cores): the longest write is the line prune
  after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to
  delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms;
  VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s
  at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's
  "about a second per million lines" was five times too fast; its "six to
  ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data.
- D-B16: which favicon is fetched, by the probe server's log: Edge 155
  the SVG, the desktop app's embedded Chromium 152 the PNG, with or
  without sizes="any" - layout.html's "vector wins where supported, raster
  catches the rest" holds; Firefox and Safari not measured.
- D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with
  today's multimap.conf: every mix of allow and deny entries matching one
  message ended reject; a changed map was used about 10 s later.
- D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999.

Stand: go vet and go test ./... pass on the server (24 packages); make
e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 02:14:58 +03:00
co-authored by Claude Opus 5.5
parent fbd0acd035
commit cad1182150
14 changed files with 104 additions and 22 deletions
+8
View File
@@ -29,6 +29,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
case, owners may be separated by blanks or continue on the next line, and
a key written twice makes the check defer, as it makes Postfix refuse the
whole table (assumptions B-PS1, B-PS4).
- **Over IPv6 every client looks the same to SelfPost** - documented, not
changed. The compose network has no IPv6, so Docker's proxy carries an
IPv6 connection and the container sees the network's gateway
(`172.18.0.1` by default), measured on a live server: all IPv6 clients
share one level-1 rate limit, and an application's client IP list cannot
name them - do not put the gateway's address on such a list, or every
IPv6 client passes it. IPv4 clients keep their own address (guide, "Rate
limiting - level 1"; assumptions C-K5).
### Fixed
+5 -1
View File
@@ -41,7 +41,11 @@ reload() {
}
# same_pair says whether a certificate file and a private key file belong
# together.
# together, by comparing the public key each one prints. openssl-x509(1) and
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
same_pair() {
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
+4
View File
@@ -288,6 +288,10 @@ postconf -e \
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf
# default, debian/templates "postfix/mynetworks", is loopback only).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
# "Client IP" is the address the container sees: an IPv4 client's own, but
# for a client over IPv6 the compose network's gateway, through Docker's
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
# C-K5; guide, "Rate limiting - level 1").
postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s"
+5 -2
View File
@@ -57,8 +57,11 @@ func runDMARCIngestMode() error {
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
// no use here: pipe(8) starts the command without the group that may write to
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
// user's one group). Whether Debian's postlog is set-gid, which would bring
// the group with it, is not recorded - assumptions A-43.
// user's one group). Nor does postlog bring the group with it: upstream
// installs it set-gid, Debian's package does not - measured in the image,
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
// postqueue (assumptions A-43).
func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
}
+4 -1
View File
@@ -16,7 +16,10 @@
# outweighs, which a refusal by the deny list still overrides. (rspamd
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
# "normal" symbol, one with an action with the "passthrough" augmentation;
# no "prefilter" setting is read.)
# no "prefilter" setting is read. Measured with this file in a throwaway
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
# matching one message - address, domain, IP - came out reject, and a
# changed map file was in use about ten seconds later.)
#
# A sender rule looks at the envelope sender only, extract_from = "smtp":
# by default multimap.lua takes the header From: when the envelope sender is
+14 -5
View File
@@ -144,7 +144,11 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
deliberately not an `accept` action: with two pre-results rspamd keeps the
one set first, and deny then won or lost by rule order (seen on rspamd
3.11, date not recorded). A refusal by the deny list overrides any score,
so deny wins whichever entries match. A sender entry is matched against
so deny wins whichever entries match - measured in a throwaway
`rspamd/rspamd:3.11.1-3` with this file, 2026-10-15: the same address or
IP on both lists, an allowed address with a denied domain or IP, and an
allowed IP with a denied address were all `reject` (assumptions
D-arch:121). A sender entry is matched against
the envelope sender only (`extract_from = "smtp"`; by default rspamd takes
a bounce's `From:` header instead - rspamd 3.11.1,
`src/plugins/lua/multimap.lua`, the `from` callback).
@@ -584,8 +588,12 @@ reads them:
send-log row carries is deleted (`PruneSendLogLines`), at start and after
every six-hourly send-log sweep. That removes the lines of rows the sweep
deleted and the lines that never had a row. The send log is asked once per
queue id; the statement still walks the table, about a second per million
lines, with the connection taken meanwhile.
queue id; the statement still walks the table, with the connection taken
meanwhile. Measured on the test server (2 cores, 2026-10-15) with 1.37
million lines, 120 days at 1 900 messages a day: 0.4 s when there is
nothing to delete, 1.9 s to delete 30 days' lines (342 000) after a gap -
under `busy_timeout`'s 5 s, and the longest a second process's write waited
meanwhile was 1.8 s (assumptions D-S7, D-arch:516).
- **Which lines are a row's.** Postfix reuses a queue id once the message has
left the queue, and a row outlives that by months. `SendLogLines` gives a row
the lines of its queue id dated from one hour before the row was written to
@@ -599,8 +607,9 @@ reads them:
catching up at start it writes a line only if the same line is not there.
- **Not stored:** what the tailer never read — the tail of a file rotated away
while the panel was down.
- **Size:** six to ten lines of about 200 bytes per message — more than the row
itself. The table is in the database and therefore in a full backup, which
- **Size:** about six lines of about 150 bytes per message — more than the row
itself (the live installation, 2026-10-15: 6.0 lines per queue id, 146 bytes
on average, over its first 18 lines; assumptions D-arch:532). The table is in the database and therefore in a full backup, which
`log/` is not ([security.md](security.md) § Backup and domain export).
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
+14 -1
View File
@@ -1026,7 +1026,7 @@ those after it move up one patch number each.
per document, reviewed. Two register proposals were outdated by this
stage's fixes (a bounce's `From:` line, `Origin: null`) and not used; the
nginx renewal needed `--webroot` in the loop, which the register missed.
- [ ] **What only the stand can answer** (A-43, C-K5, C-M8, C-X1, D-S7,
- [x] **What only the stand can answer** (A-43, C-K5, C-M8, C-X1, D-S7,
D-B16 and the document claims marked so): postlog's set-gid in the
image; the client address Postfix sees through Docker's port publishing
over IPv4 and IPv6; the largest DMARC report received; the public keys
@@ -1034,6 +1034,19 @@ those after it move up one patch number each.
against busy_timeout at the retention limit; the favicon each browser
takes. Each measured, dated and written beside the code it settles.
- **Opus**
*Done 2026-10-15,* each written beside what it settles: postlog is not
set-gid in Debian's package (A-43); Docker's proxy hands every IPv6
client and the host's loopback over as the gateway, IPv4 keeps the
client (C-K5; a new guide paragraph, and a Security note: an allow-list
naming the gateway admits every IPv6 client); one DMARC report of one
record so far, too little to judge the cap (C-M8); OpenSSL 3.0.22 prints
one public key alike for RSA and ECDSA (C-X1); the longest write, the
line prune, 1.9 s at 1 900 messages a day after a 30-day gap, under
busy_timeout (D-S7, D-arch:516); about 6 lines of 146 bytes a message
(D-arch:532); Edge takes the SVG favicon, the desktop app's browser the
PNG (D-B16); a published port nothing listens on is refused over IPv4,
accepted by the proxy otherwise (D-guide:61); deny beats allow in every
mix on rspamd 3.11.1-3 (D-arch:121); rspamd runs as uid 11333 (D-sec:424).
- [ ] Docs and wording: help topic sender-check and guide § Who an
application may send as (mockup first), security.md § Mail path,
CHANGELOG. - **Sonnet**
+27 -6
View File
@@ -64,8 +64,14 @@ changed (see the CHANGELOG, *Removed*).
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
**`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
[DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
ports in external scans.
[DMARC reports](#dmarc-reports)). A scan over IPv4 sees such a port closed -
the connection is refused. Over IPv6 the connection goes through Docker's
proxy (the host runs `docker-proxy` on `::` for every published port), which
accepts it and then closes it, so a scanner may report it open. Measured on a
live server, 2026-10-15, Docker 29.6.1, with a published port nothing
listened on: refused from outside over IPv4 and from the host's public IPv4
address; accepted and closed through `127.0.0.1`, the same proxy. IPv6 itself
was not probed for this.
### Local trial
@@ -230,7 +236,7 @@ expected to set; defaults match the code exactly.
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it). | *(empty — XFF ignored)* | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
@@ -669,7 +675,18 @@ Level 1 is always on, configured via `.env`:
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
This is an anvil limit per client IP and listener. It keeps working even if the
journal-milter (level 2) is down. There is no per-IP bypass except the container's own loopback.
journal-milter (level 2) is down. There is no per-IP bypass except the
container's own loopback.
**The client IP is the real one over IPv4 only.** Docker's port publishing
keeps an IPv4 client's address, but the compose network has no IPv6, so a
client that reaches the server over IPv6 is carried by Docker's proxy and
arrives from the network's gateway (`172.18.0.1` by default) - and so does a
connection from the host to `127.0.0.1`. All such clients share one level-1
budget, an application's client IP list cannot name them, and the spam
filter sees the gateway. Measured on a live server, 2026-10-15, Docker
29.6.1: an outside IPv4 client and the host's own public IPv4 address logged
as themselves, the host's IPv6 address and `127.0.0.1` as `172.18.0.1`.
### Full backup and restore
@@ -985,8 +1002,9 @@ the same way as the nginx/Caddy fragments:
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
```
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
The milter sees the real client IP (over IPv4; see [Rate limiting — level
1](#rate-limiting--level-1-ip-backstop) for IPv6), HELO and PTR — unlike the
upstream, which only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
refuse with a temporary error instead, so the sending server tries again.
@@ -1283,6 +1301,9 @@ application: when enabled, list one or more client IPs that may **submit** mail
as that application; a session from any other address is refused with a 4xx at
`MAIL FROM`. When disabled, any client IP is allowed. This is independent of
rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
An application that connects over IPv6 is seen from the Docker network's
gateway, not its own address (see [Rate limiting — level
1](#rate-limiting--level-1-ip-backstop)).
It is **not an authentication boundary.** The SASL login itself still succeeds;
the check runs in the journal-milter, on the same fail-open path as level 2
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 45/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`; next stage 2c → `2.5.2`) | 46/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
+3 -1
View File
@@ -452,7 +452,9 @@ deferred item from the roadmap.
DKIM to pass.
- **The filter lists are files readable by anyone on the host.**
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
the filter runs under another user in another container. They hold sender
the filter runs under another user in another container (rspamd as
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`; the panel is uid 999 -
measured on a live server, 2026-10-15). They hold sender
addresses, domains and networks - who the operator blocks and whom he lets
through - but no message data. The inbound journal itself is in the
database, 0640. Trigger to revisit: a shared host, where the sidecar should
+4 -1
View File
@@ -84,7 +84,10 @@ type feedbackXML struct {
// and eight megabytes of it (maxAggregateXML) fit some 160 000 empty records or
// one name of eight megabytes - every one of them a row or a table cell later.
// A real report has tens of records, a few thousand from the largest
// receivers, and no field longer than a line.
// receivers, and no field longer than a line - an estimate: the live
// installation had received one report by 2026-10-15, of one record, which
// says nothing yet about the largest (assumptions C-M8: keep this ten times
// above the largest seen).
const (
maxReportRecords = 5000
maxFieldLen = 255
+4 -1
View File
@@ -11,7 +11,10 @@ import (
// the connecting client IP. When the application has no IP restriction the
// check passes. When it has one and the client's address is not known, the
// check fails: an address list that an unknown address satisfies is no list.
// Store errors are fail-open (see overLimit).
// Store errors are fail-open (see overLimit). The address is the one Postfix
// sees in the container: a client's own over IPv4, the Docker network's
// gateway for every client over IPv6 (measured on a live server, 2026-10-15;
// assumptions C-K5).
func (s *session) authIPAllowed() bool {
if s.login == "" {
return true
+6 -1
View File
@@ -148,7 +148,12 @@ func (s *Store) SendLogLines(row SendLogRow, lifetime time.Duration, n int) ([]S
// received, whose row the journal milter has yet to write.
//
// The send log is asked once per queue id, not once per line: the ids are read
// off the index, and a message is several lines.
// off the index, and a message is several lines. It is the longest write the
// panel makes: measured on the test server, 2026-10-15 (2 cores; 1.37
// million lines, 120 days at 1 900 messages a day, timed through this
// package), 1.9 s to delete 342 000 lines after a 30-day gap, 0.4 s with
// nothing to delete - inside busy_timeout's 5 s (Open), which dmarc-ingest's
// writes wait through: the longest waited 1.8 s (assumptions D-S7).
func (s *Store) PruneSendLogLines(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log_lines
+5 -1
View File
@@ -22,7 +22,11 @@
the two-line wordmark, which stops resolving below 32px. The PNG is declared
first and the SVG second: a browser takes the last icon whose type it
understands, so vector wins wherever it is supported and the raster catches
the rest. */}}
the rest. HTML links.html#rel-icon leaves "most appropriate" to the
browser, so measured, 2026-10-15, by which file was requested: Edge 155
(Chromium) takes the SVG, the Claude desktop app's embedded browser
(Chromium 152) the PNG, and adding sizes="any" to the SVG changes
neither. Firefox and Safari not measured (assumptions D-B16). */}}
<link rel="icon" href="/static/favicon.png" sizes="32x32">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/bulma.min.css">