cert-reload: renew Postfix's copy of a renewed certificate (audit H2)

postfix-config.sh copies the mounted certificate and key into
/etc/postfix/tls-internal at start (root-owned, key 0600) and Postfix reads
the copy; cert-reload only ran `postfix reload` daily, which re-read the
stale copy, so a certificate renewed while the container ran expired on
465/587 with the old one.

postfix-cert-reload.sh now, every TLS_RELOAD_INTERVAL_SECONDS: compares the
mount with the copy (cmp); when they differ and the certificate and key
belong together (openssl public keys equal - a renewer writes them one
after the other), writes each file beside its target, root-owned with the
start's modes, renames it over the target, and reloads Postfix. Nothing
changed - no reload. No copy (files missing at start) - the old periodic
reload. Never exits non-zero.

e2e renewed_certificate_picked_up: writes a new certificate into the
stand's mount and waits (30 s) for port 465 to present its serial;
compose.override.yml sets TLS_RELOAD_INTERVAL_SECONDS=2.
supervisord.conf comment, architecture.md (process table, § Postfix TLS
certs, the env knob), guide (after the proxies: picked up within a day).
CHANGELOG [Unreleased], audit plan line ticked, roadmap 12/61.

Evidence: GOOS=linux go vet in test/e2e clean; the server run of the stage
follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:25:20 +03:00
co-authored by Claude Opus 5.5
parent 63520f1678
commit 7af37654a1
10 changed files with 179 additions and 26 deletions
+8
View File
@@ -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
+61 -17
View File
@@ -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
+3 -2
View File
@@ -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]
+10 -5
View File
@@ -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`).
+1 -1
View File
@@ -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**
+4
View File
@@ -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
+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`; 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
+81
View File
@@ -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
})
}
+4
View File
@@ -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"
+6
View File
@@ -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 {