audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s
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:
+14
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user