diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0c70d..e36cc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version program, as the other patterns of the log tailer are (audit 2026-10-12, M12). +- **A renewed TLS certificate is served without a restart.** Postfix reads a + copy of the mounted certificate made when the container starts, and the + daily reload re-read that copy, not the renewed files: a certificate renewed + while the container ran expired on 465 and 587 at the old one's end. The + daily job now compares the mount with the copy and, when the certificate and + key belong together, renews the copy and reloads Postfix; when nothing + changed it no longer reloads (audit 2026-10-12, H2). + ### Added - **The delivery log measures its own guesses** (migration `0007`). A table diff --git a/build/postfix-cert-reload.sh b/build/postfix-cert-reload.sh index 145a5c1..543fb2b 100644 --- a/build/postfix-cert-reload.sh +++ b/build/postfix-cert-reload.sh @@ -1,25 +1,69 @@ #!/bin/sh -# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec -# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every -# few months; Postfix only re-reads them on reload. A simple daily reload is -# more than enough (a day of staleness is harmless) and far simpler than an -# inotify watcher — the spec explicitly prefers this. +# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4; +# audit 2026-10-12, H2). # -# Runs under supervisord as root, so it can reload Postfix directly. It sleeps -# first, then reloads in a loop: no reload at container start (the wrapper is -# still bringing Postfix up then) and none until at least one interval has -# passed. A reload is harmless when nothing changed. -set -eu +# Postfix does not read the mounted files: postfix-config.sh copies them at +# start into /etc/postfix/tls-internal, because Postfix wants the key owned by +# root with mode 0600, which a read-only bind mount owned by the host's user +# cannot give. A renewal of the mounted files therefore reaches Postfix only +# through a new copy. Every interval this compares the mounted files with the +# copy; when they differ, and the certificate and the key belong together (a +# renewer writes them one after the other), it replaces the copy - each file +# written beside its target and renamed over it, with the owner and modes of +# the start - and reloads Postfix. When nothing changed it does nothing. +# +# Runs under supervisord as root. It sleeps first: at container start the +# wrapper is still bringing Postfix up. It never exits non-zero, so a failure +# does not trip the crashexit listener; it is logged and tried again. +set -u INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}" +SRC_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" +SRC_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" +DIR=/etc/postfix/tls-internal + +reload() { + if postfix reload; then + echo "cert-reload: postfix reloaded ($1)" + else + echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2 + fi +} + +# same_pair says whether a certificate and a private key belong together. +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 + [ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ] +} + +# install_copy SRC DST MODE: DST becomes a root-owned copy of SRC in one rename. +install_copy() { + cp -f "$1" "$2.new" && chown root:root "$2.new" && chmod "$3" "$2.new" && mv -f "$2.new" "$2" +} + +refresh() { + if [ ! -f "$DIR/fullchain.pem" ]; then + # No copy: the mounted files were missing at start and Postfix was + # pointed at them as they are. A reload is what picks up a change there. + reload "periodic TLS refresh" + return + fi + if cmp -s "$SRC_CERT" "$DIR/fullchain.pem" && cmp -s "$SRC_KEY" "$DIR/privkey.pem"; then + return + fi + if ! same_pair "$SRC_CERT" "$SRC_KEY"; then + echo "cert-reload: the mounted certificate and key differ from Postfix's copy but do not belong together; will look again after ${INTERVAL}s" >&2 + return + fi + if install_copy "$SRC_KEY" "$DIR/privkey.pem" 0600 && install_copy "$SRC_CERT" "$DIR/fullchain.pem" 0644; then + reload "renewed certificate" + else + echo "cert-reload: copying the renewed certificate failed, will retry after ${INTERVAL}s" >&2 + fi +} while true; do sleep "${INTERVAL}" - if postfix reload; then - echo "cert-reload: postfix reloaded (periodic TLS refresh)" - else - # Never exit non-zero: a transient reload failure must not trip the - # crashexit listener and take the container down. Log and retry next cycle. - echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2 - fi + refresh done diff --git a/build/supervisord.conf b/build/supervisord.conf index 7940185..c66c5ce 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -89,8 +89,9 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -; Periodic TLS-certificate refresh. Runs a daily `postfix reload` -; so certificates the reverse-proxy renews in the read-only mount are picked up. +; TLS-certificate refresh. Once a day it compares the certificate the +; reverse-proxy renews in the read-only mount with the copy Postfix reads, and +; when they differ copies it again and runs `postfix reload`. ; 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. [program:cert-reload] diff --git a/docs/architecture.md b/docs/architecture.md index 0821fe4..c717212 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)): | `panel` | panel | 200 | HTTP UI + sender check + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter | | `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the journal milter's and, with the inbound relay on, the inbound journal milter's) | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | -| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs | +| `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it | | `logrotate` | root | 400 | Periodic `mail.log` rotation | Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper @@ -151,8 +151,12 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the - **Level-1 rate limit** — `smtpd_client_message_rate_limit` / `anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter. - **Chroot disabled** for all services (DNS/TLS inside container). -- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily - reload via `cert-reload`. +- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied + at start into `/etc/postfix/tls-internal` (Postfix wants the key root-owned + and `0600`, which the mount cannot give), and Postfix reads the copy. + `cert-reload` compares the mount with the copy daily; when they differ and + the certificate and key belong together it renews the copy (each file + renamed into place) and reloads Postfix. ### OpenDKIM @@ -920,8 +924,9 @@ unsupported rather than as a missing doc: - **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_WAIT_TIMEOUT` (`30` seconds). -- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily - `postfix reload` to pick up renewed certificates), +- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — how + often a renewed certificate is looked for; Postfix is reloaded only when one + is found), `LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six hours; logrotate keeps 14 rotated files on a daily schedule, and each rotation triggers `postfix reload`). diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index 3cafb21..de54fc1 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -629,7 +629,7 @@ has already run is never edited — the next file is added; the published priority follows `tailer_blind_seconds`. - [x] **Anchor `deliveryRe`** at the head of the line like its two siblings; test with the probed HELO and quoted-sender lines. — **Opus** -- [ ] **TLS re-copy** (H2): `postfix-cert-reload.sh` compares the mounted files +- [x] **TLS re-copy** (H2): `postfix-cert-reload.sh` compares the mounted files to the copy and re-copies before `postfix reload` (atomically, same modes); e2e replaces the cert in the stand and reads the new serial off 465. — **Opus** diff --git a/docs/guide.md b/docs/guide.md index 0ae95cd..3973331 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -333,6 +333,10 @@ docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals. +Whatever the proxy, SelfPost notices a renewed certificate in the mount within +a day (`TLS_RELOAD_INTERVAL_SECONDS`) and starts serving it on 465 and 587 +without a restart. + ## Instance administration After sign-in the global role opens on **Overview** — the place to answer "is diff --git a/docs/roadmap.md b/docs/roadmap.md index 3b1a0d5..2406a5c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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`; next stages 1+2 → `2.5.0`) | 11/61 | [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`; next stages 1+2 → `2.5.0`) | 12/61 | [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 diff --git a/test/e2e/cert_reload_check.go b/test/e2e/cert_reload_check.go new file mode 100644 index 0000000..acf1371 --- /dev/null +++ b/test/e2e/cert_reload_check.go @@ -0,0 +1,81 @@ +package e2e + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// servedSerial is the serial of the certificate Postfix presents on the +// implicit-TLS port. +func servedSerial() (*big.Int, error) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", smtpsAddr, &tls.Config{ + InsecureSkipVerify: true, + ServerName: selfpostHostname, + }) + if err != nil { + return nil, err + } + defer conn.Close() + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + return nil, fmt.Errorf("no certificate presented") + } + return certs[0].SerialNumber, nil +} + +func fileSerial(path string) (*big.Int, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("%s holds no PEM block", path) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + return cert.SerialNumber, nil +} + +// checkCertRenewalPickedUp replaces the certificate in the stand's mount, as +// a renewer would, and waits for port 465 to present it without a restart. +// Postfix reads a copy made at start (postfix-config.sh); until 2.5.0 nothing +// renewed that copy, and a renewed certificate reached Postfix only when the +// container was restarted (audit 2026-10-12, H2). compose.override.yml sets +// TLS_RELOAD_INTERVAL_SECONDS to 2 so this does not wait a day. +func checkCertRenewalPickedUp(s *stack) error { + certPath := filepath.Join(s.stageDir, "certs", "fullchain.pem") + before, err := servedSerial() + if err != nil { + return fmt.Errorf("read the served certificate: %w", err) + } + if err := writeSelfSignedCert(certPath, filepath.Join(s.stageDir, "certs", "privkey.pem")); err != nil { + return fmt.Errorf("renew the certificate: %w", err) + } + want, err := fileSerial(certPath) + if err != nil { + return err + } + if want.Cmp(before) == 0 { + return fmt.Errorf("the renewed certificate has the old serial %s", before) + } + return waitFor("port 465 to present the renewed certificate", 30*time.Second, 500*time.Millisecond, func() (bool, error) { + got, err := servedSerial() + if err != nil { + return false, err + } + if got.Cmp(want) != 0 { + return false, fmt.Errorf("serial %s served, want %s", got, want) + } + return true, nil + }) +} diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index ce7256e..9f16fbf 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -48,6 +48,10 @@ services: # loops well past this count) is meant to hit it. RATE_LIMIT_MESSAGES_PER_IP: "50" RATE_LIMIT_WINDOW_SECONDS: "60" + # cert-reload compares the mounted certificate with Postfix's copy this + # often, so renewed_certificate_picked_up does not wait a day. It + # reloads Postfix only when the certificate changed. + TLS_RELOAD_INTERVAL_SECONDS: "2" ports: !override - "20465:465" - "20587:587" diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 0def398..da36c21 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -147,6 +147,12 @@ func TestE2E(t *testing.T) { } }) + run("renewed_certificate_picked_up", func(t *testing.T) { + if err := checkCertRenewalPickedUp(h); err != nil { + t.Fatal(err) + } + }) + run("setup_and_login", func(t *testing.T) { token, err := readSetupToken(h) if err != nil {