base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
test / test (push) Canceled after 0s

The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.

What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
  (smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
  port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
  deprecated-parameter warning on stderr made one value too many and the
  panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
  the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
  07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
  explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
  opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
  cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
  trixie a package took 998 and the build failed (owner's decision:
  a conflict-free id, the distribution's own ids untouched). The
  entrypoint hands /data back by name when the user or the group differs.
  Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 19:31:35 +03:00
co-authored by Claude Opus 5.5
parent 68ec8b274c
commit 1c6e04b7d3
80 changed files with 970 additions and 454 deletions
+22 -9
View File
@@ -191,9 +191,22 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
- **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** —
`smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix
3.7.11, `src/smtpd/smtpd.c`, the DATA loop at 3649: a `.` line next to a
bare `<LF>` is dropped, not taken as the end); a bare `<LF>` elsewhere is
read as `<CR><LF>`. The submission ports keep Postfix's default.
3.10.13, `src/smtpd/smtpd.c`, `receive_data_message()`, the DATA loop at
3713: a `.` line next to a bare `<LF>` is dropped, not taken as the end); a
bare `<LF>` elsewhere is read as `<CR><LF>`. The submission ports keep
Postfix's default, which is the same `normalize` since Postfix 3.9
(`src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`); the image ran
Postfix 3.7.11 until `2.7.0`, whose default was `no`, hence the explicit
setting on port 25.
- **The submission ports hide the application in `Received:`** (`2.7.0`) —
`smtpd_hide_client_session=yes` on `smtps/inet` and `submission/inet`:
Postfix's header reads `Received: by <hostname> (Postfix) id <queue id>`,
with the recipient when there is one, and the date - no HELO name, client
name or address, TLS, login or protocol (Postfix 3.10.13,
`src/smtpd/smtpd.c:3495` and `:3627-3645`). Port 25 keeps the full
header, which RFC 5321 requires there (`proto/postconf.proto:19587-19589`).
The queue id in the header leads to the message's row in the panel's log
and to smtpd's `client=` line in mail.log.
- **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
@@ -234,7 +247,7 @@ milter, § Postfix above, port 25):
`delay_warning_time`), reads each as Postfix does - one number and one
unit, the parameter's own default unit when there is none (`5d` for the
lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.7.11,
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.10.13,
`src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on
the handlers config. The
Outbound queue box and a delivery's `deferred` / `bounced` history print those
@@ -295,7 +308,7 @@ server itself.
reason), then `@domain` - and the **first of these keys in the table
decides**, its owners alone: an address listed for one application is not
open to another that holds the whole domain. Logins are compared without
regard to case, as Postfix compares them (Postfix 3.7.11,
regard to case, as Postfix compares them (Postfix 3.10.13,
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
twice makes Postfix refuse the whole table, and the check defers then too.
@@ -461,7 +474,7 @@ recreates the file on its first line after the reload, as root and mode
`0600`, which the unprivileged panel user cannot read). `follow()` drains the
old inode once more before switching descriptors, and keeps it open and reads
it for a minute after: postlogd keeps writing to the renamed file until the
reload makes it exit, seconds later (Postfix 3.7.11, src/postlogd/postlogd.c;
reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing
log file as an empty tail, not an error.
@@ -494,7 +507,7 @@ the lines stored under it - only those of the minutes around the refusal, since
Postfix frees the id at once. A message the journal milter refused at
`MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL
from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is
Postfix's last field on an authenticated session (Postfix 3.7.11,
Postfix's last field on an authenticated session (Postfix 3.10.13,
`src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no
application's - and puts it on the nearest refused row of that client
address, login and sender within two minutes
@@ -513,7 +526,7 @@ a kill is covered as well as a clean stop.
**Expiry.** A message Postfix gives up on at the end of its queue lifetime has
no line per recipient: qmgr writes one line for the message, `<id>:
from=<...>, status=expired, returned to sender` (`force-expired` after
`postsuper -e`; Postfix 3.7.11, `src/qmgr/qmgr_active.c`,
`postsuper -e`; Postfix 3.10.13, `src/qmgr/qmgr_active.c`,
`qmgr_active_done_25_generic()`), and the recipients' last lines say
`deferred`. The tailer reads that line and closes the message's open rows -
each recipient's newest, `queued` or `deferred` - as `bounced`.
@@ -542,7 +555,7 @@ of the id's flags (`src/postqueue/showq_compat.c`, `showq_message()`).
**Which row a delivery line is.** The journal milter writes a row's
recipient as Postfix will log it: the address the client gave, its quoted
local part unquoted and quoted again only where it needs to be (`"ab"@x`
becomes `ab@x`, `"a b"@x` stays; Postfix 3.7.11, `tok822_parse.c`,
becomes `ab@x`, `"a b"@x` stays; Postfix 3.10.13, `tok822_parse.c`,
`quote_822_local.c`), case kept, and a recipient given twice - in any case -
once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()`
folding case). A delivery line names it in `orig_to=` when Postfix rewrote
+8
View File
@@ -38,6 +38,14 @@ versions `go.mod` pins (go-milter v0.4.1, modernc.org/sqlite v1.60.1 with
SQLite 3.53.4); htmx 2.0.4 as vendored. Upstream paths below are relative
to the package's source tree.
Since base-trixie (`2.7.0`) the image runs Debian trixie (Postfix 3.10.13,
opendkim 2.11.0~beta2-9.1+b1, cyrus-sasl2 2.1.28+dfsg1-9, supervisor
4.2.5-3, logrotate 3.22.0-1; development.md § Code that relies on another
component's behaviour), and the citations in the code were re-read against
the trixie sources. This register is left as it was made: its line numbers
and versions are those of the bookworm sources above, which are now kept
under `dev/upstream-bookworm/` (where it says `dev/upstream/`).
Counts at the inventory, by the justification the tree had:
| Part | Entries | CITED | MEASURED | GUESSED | WRONG? |
+19 -18
View File
@@ -152,8 +152,8 @@ commit rule but **no** version cut until the next product stage ships.
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) |
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
| **Container** | Docker + Compose v2 on the dev host and in CI |
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate |
| **Image (build stage)** | `golang:1.26-trixie` (bookworm until `2.7.0`) — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
| **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, OpenDKIM, supervisord, SASL, logrotate |
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
| **Image registry** | `ghcr.io/mixeme/selfpost` |
@@ -209,8 +209,8 @@ the image.
### Debian packages in the runtime image
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian bookworm repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/bookworm/>.
from Debian trixie repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/trixie/>.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
@@ -225,21 +225,22 @@ lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
through go-milter, relies on SQLite's locking, on logrotate's and Docker's
behaviour. Every block of code that does so carries a comment that says
what it relies on and where that is written, for the version the image runs
(the Debian bookworm package, not upstream's head):
(the Debian trixie package, not upstream's head):
```go
// Postfix 3.7.11, src/smtpd/smtpd_check.c,
// Postfix 3.10.13, src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): the SASL login is compared with
// each owner in sender_login_maps by strcasecmp_utf8.
```
The Debian sources of the image's versions are fetched with `apt-get source`
inside a `debian:bookworm` container (`postfix`, `opendkim`, `cyrus-sasl2`,
inside a `debian:trixie` container (`postfix`, `opendkim`, `cyrus-sasl2`,
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
(not in git) is where they are kept for reading. Go modules are read in the
module cache at the version `go.mod` pins.
(not in git) is where they are kept for reading, and `dev/upstream-bookworm/`
holds the bookworm ones the citations named before `2.7.0`. Go modules are
read in the module cache at the version `go.mod` pins.
The image takes `debian:bookworm-slim` and installs without pinning (pinned
The image takes `debian:trixie-slim` and installs without pinning (pinned
Debian versions leave the mirrors), so a rebuild after a point or security
release runs packages the citations were not read against. The versions
read are these; the e2e stand compares them with `dpkg-query` in the image
@@ -249,14 +250,14 @@ re-read what changed, update the citations, then this table:
<!-- cited-package-versions -->
| Package | Version read |
|---|---|
| `postfix` | `3.7.11-0+deb12u1` |
| `opendkim` | `2.11.0~beta2-8+deb12u1` |
| `libopendkim11` | `2.11.0~beta2-8+deb12u1` |
| `libmilter1.0.1` | `8.17.1.9-2+deb12u2` |
| `libsasl2-2` | `2.1.28+dfsg-10` |
| `sasl2-bin` | `2.1.28+dfsg-10` |
| `supervisor` | `4.2.5-1` |
| `logrotate` | `3.21.0-1` |
| `postfix` | `3.10.13-0+deb13u1` |
| `opendkim` | `2.11.0~beta2-9.1+b1` |
| `libopendkim11` | `2.11.0~beta2-9.1+b1` |
| `libmilter1.0.1` | `8.18.1-6+b1` |
| `libsasl2-2` | `2.1.28+dfsg1-9` |
| `sasl2-bin` | `2.1.28+dfsg1-9` |
| `supervisor` | `4.2.5-3` |
| `logrotate` | `3.22.0-1` |
<!-- /cited-package-versions -->
A document instead of source: its URL and section. Where neither answers the
+21 -7
View File
@@ -73,6 +73,20 @@ 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.
Since `2.7.0` the image runs Postfix 3.10, whose defaults hold every
listener - 465 and 587 as well as 25 - to two rules of SMTP: a message ends
only at the standard `<CR><LF>.<CR><LF>` (a client that ends DATA with a
bare newline waits for a reply that does not come), and a client that sends
commands ahead of the server's replies where RFC 2920 does not allow it is
answered `554 5.5.0 Error: SMTP protocol synchronization` and disconnected.
Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
where the application runs: the `Received:` header SelfPost adds reads
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
without the application's address, host name or login. To trace a message
someone complains about, take that queue id to *Outbound › Log*; mail.log
keeps the client address on smtpd's `client=` line for the same id.
### Local trial
The [README quick start](../README.md#quick-start) runs a single container
@@ -312,13 +326,13 @@ ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
```
The panel reads them as its own user (Health shows the certificate, a full
backup carries both files), through the container's group 998. certbot's
backup carries both files), through the container's group 10998. certbot's
documentation gives the way for a server that is not root, and certbot keeps
the key's group and mode at every renewal:
```sh
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
```
@@ -339,7 +353,7 @@ docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
The fragment mounts certbot's directory into both nginx and SelfPost, at
`/etc/letsencrypt`; `./certs` holds two links to
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
container, not on the host — and the panel reads the key through group 998,
container, not on the host — and the panel reads the key through group 10998,
as in the Apache case. Then start everything:
```sh
@@ -347,7 +361,7 @@ mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
@@ -714,11 +728,11 @@ instance keeps running, what goes into the archive, and what happens on restore.
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below. The panel runs as its own user, not as
root, so **the private key in `certs/` has to be readable by it**: give the
file to the container's `selfpost` group, which has the fixed id 998 and holds
file to the container's `selfpost` group, which has the fixed id 10998 and holds
both the panel and Postfix —
```sh
chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem
chgrp 10998 certs/privkey.pem && chmod 640 certs/privkey.pem
```
— and have whatever renews the certificate do the same; certbot does it by
@@ -795,7 +809,7 @@ methods avoid it by stopping first.
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `certs/` private key | Yes (the panel needs it readable by group 10998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No |
+3 -3
View File
@@ -827,9 +827,9 @@ supervisord; `dev/upstream` refetched from trixie; the cited-package table
and `checkCitedPackageVersions`; the e2e sink image; `opendkim-tools`
dropped from the image (used by nothing at run time). OpenDKIM's code is
the same in trixie, its citations stand. One stage, a MINOR release. Also
gained: `smtpd_hide_client_session` (3.10) is available - whether the
relay should hide the application's address from `Received:` is a product
question to decide separately.
gained: `smtpd_hide_client_session` (3.10); the owner decided on
2026-10-15 to turn it on for 465 and 587 in the same release, so the
`Received:` header no longer shows the application's address.
## panel-dkim-signing
+18 -6
View File
@@ -218,6 +218,14 @@ operator can act on it.
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **A recipient does not learn where an application runs (`2.7.0`).** The
`Received:` header Postfix adds on 465 and 587 carries no client name,
address, HELO name, TLS details or login (`smtpd_hide_client_session=yes`,
the owner's decision of 2026-10-15): before, every message told its
recipients the address and the internal host name of the machine the
application sent from. What a complaint needs stays with the operator - the
queue id in the header, the panel's log and smtpd's `client=` line in
mail.log. Port 25 keeps the full header, as RFC 5321 requires of an MTA.
- **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the
key by the `From:` header and Postfix holds only the envelope sender
against the login, so until `2.4.0` an application of one sending domain
@@ -264,12 +272,16 @@ operator can act on it.
on inbound. An optional antispam milter is inbound-only; default action is
fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the
standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the
2023 SMTP-smuggling defence, which Postfix 3.7 leaves off by default): a
2023 SMTP-smuggling defence, which Postfix 3.7 left off by default): a
`.` line next to a bare newline is body, and what a smuggler would put
behind it is delivered as part of the one message, not read as a second
transaction. The submission ports keep the default - there a smuggled
transaction is still the authenticated session's, under its sender
bindings and milters ([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
transaction. The submission ports keep the default. Since `2.7.0` the
image runs Postfix 3.10.13, whose default is `normalize` too (Postfix 3.9
and later; `src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`), so
they end DATA the same way; the port-25 setting stays explicit. Before
`2.7.0` a smuggled transaction on a submission port was still the
authenticated session's, under its sender bindings and milters
([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
- **The panel's milters judge every message of a connection.** Postfix
introduces a client to a milter once per connection, and the milter library
replaces the milter's session after any reply that is not "continue". Until
@@ -303,7 +315,7 @@ operator can act on it.
parsed from `mail.log` from the head of the line, with the reply ending at
Postfix's own `; from=<` so that the HELO name after it - the client's text,
which Postfix logs with non-printable characters and ` <>()\";@` turned into
`?` (Postfix 3.7.11, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept.
Only a recipient at an inbound domain makes a row. The journal is cut to
10 000 rows every 500 rows the tailer writes, and by age every six hours.
@@ -524,7 +536,7 @@ deferred item from the roadmap.
§ Roles.
- **Level-2 in-flight reservations can outlive a broken SMTP session until TTL.**
Postfix tells the milters ABORT when a client drops the connection
mid-transaction (Postfix 3.7.11, src/smtpd/smtpd.c `mail_reset()` ->
mid-transaction (Postfix 3.10.13, src/smtpd/smtpd.c `mail_reset()` ->
`milter_abort()`), and the journal milter releases the reservation then.
When that never arrives - smtpd killed, the milter connection lost - go-milter
has no close callback, and the in-memory reservation is released by TTL