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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user