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,
|
program, as the other patterns of the log tailer are (audit 2026-10-12,
|
||||||
M12).
|
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
|
### Added
|
||||||
|
|
||||||
- **The delivery log measures its own guesses** (migration `0007`). A table
|
- **The delivery log measures its own guesses** (migration `0007`). A table
|
||||||
|
|||||||
@@ -1,25 +1,69 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
|
||||||
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
# audit 2026-10-12, H2).
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
# Postfix does not read the mounted files: postfix-config.sh copies them at
|
||||||
# first, then reloads in a loop: no reload at container start (the wrapper is
|
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
|
||||||
# still bringing Postfix up then) and none until at least one interval has
|
# root with mode 0600, which a read-only bind mount owned by the host's user
|
||||||
# passed. A reload is harmless when nothing changed.
|
# cannot give. A renewal of the mounted files therefore reaches Postfix only
|
||||||
set -eu
|
# 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}"
|
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
|
while true; do
|
||||||
sleep "${INTERVAL}"
|
sleep "${INTERVAL}"
|
||||||
if postfix reload; then
|
refresh
|
||||||
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
|
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
; TLS-certificate refresh. Once a day it compares the certificate the
|
||||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
; 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
|
; 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.
|
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||||
[program:cert-reload]
|
[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 |
|
| `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` | 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) |
|
| `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 |
|
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||||
|
|
||||||
Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper
|
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` /
|
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||||
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
|
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
|
||||||
reload via `cert-reload`.
|
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
|
### OpenDKIM
|
||||||
|
|
||||||
@@ -920,8 +924,9 @@ unsupported rather than as a missing doc:
|
|||||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — how
|
||||||
`postfix reload` to pick up renewed certificates),
|
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
|
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
||||||
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
||||||
rotation triggers `postfix reload`).
|
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`.
|
priority follows `tailer_blind_seconds`.
|
||||||
- [x] **Anchor `deliveryRe`** at the head of the line like its two siblings;
|
- [x] **Anchor `deliveryRe`** at the head of the line like its two siblings;
|
||||||
test with the probed HELO and quoted-sender lines. — **Opus**
|
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);
|
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.
|
e2e replaces the cert in the stand and reads the new serial off 465.
|
||||||
— **Opus**
|
— **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.
|
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
|
## Instance administration
|
||||||
|
|
||||||
After sign-in the global role opens on **Overview** — the place to answer "is
|
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) |
|
| 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) |
|
| 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) |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
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.
|
# loops well past this count) is meant to hit it.
|
||||||
RATE_LIMIT_MESSAGES_PER_IP: "50"
|
RATE_LIMIT_MESSAGES_PER_IP: "50"
|
||||||
RATE_LIMIT_WINDOW_SECONDS: "60"
|
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
|
ports: !override
|
||||||
- "20465:465"
|
- "20465:465"
|
||||||
- "20587:587"
|
- "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) {
|
run("setup_and_login", func(t *testing.T) {
|
||||||
token, err := readSetupToken(h)
|
token, err := readSetupToken(h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user