Security audit 2026-10-17, finding I1, stage 2; the help mockup is ff3a118.
The guide had operators give the TLS private key to group 10998 so the
panel can archive it, and the Dockerfile puts postfix in that group (for
sasldb2 and the maps): `docker exec -u postfix head privkey.pem` printed
the live key. postfix-config.sh copies the key to a root-only directory
precisely so that a compromised Postfix daemon cannot read it - the mounted
original undid that.
Now a group backupkey (gid 10997, outside the system range like 10998)
whose one member is the panel; the guide (Apache/certbot, nginx, Taking a
full backup, the backup table), Help, and the backup's refusal name 10997
and say why; postfix-config.sh, extract-cert.sh and the Dockerfile's
comments follow. A key still given to 10998 keeps working for the backup.
CHANGELOG has an Upgrading note.
e2e: the stage gives its key to 10997 at 0640 when the runner is root
(stageKeyMode; 0644 otherwise), and testFullBackupRestore, after the
refusal that now names chgrp 10997, checks that the postfix uid cannot read
/etc/postfix/tls/privkey.pem and the panel can.
Docs: security.md § Output and process (the groups); guide; CHANGELOG;
plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
3.1 KiB
Bash
Executable File
76 lines
3.1 KiB
Bash
Executable File
#!/bin/bash
|
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
|
# Traefik keeps its certificates there, so a PEM extraction step is required.
|
|
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
|
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
|
# by SelfPost/Postfix.
|
|
#
|
|
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
|
#
|
|
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
|
|
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
|
|
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
|
|
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
|
|
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
|
|
# docker-compose.traefik.yml names.
|
|
#
|
|
# Nothing is replaced unless both new files are PEM: a domain acme.json does
|
|
# not hold, an empty field or a broken file stops the script with an error and
|
|
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
|
|
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
|
|
# certificate; Postfix's TLS broke at its next restart). The new files are
|
|
# written beside the old ones, the owner's alone until they are whole, then
|
|
# renamed into place.
|
|
set -euo pipefail
|
|
|
|
ACME_JSON="${1:?path to acme.json}"
|
|
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
|
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
|
|
|
mkdir -p "$OUT_DIR"
|
|
umask 077
|
|
|
|
die() {
|
|
echo "extract-cert: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
# field prints the base64 of one field of the domain's first certificate, or
|
|
# nothing.
|
|
field() {
|
|
jq -r --arg domain "$DOMAIN" --arg field "$1" '
|
|
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
|
|
' "$ACME_JSON"
|
|
}
|
|
|
|
cert_b64=$(field certificate)
|
|
key_b64=$(field key)
|
|
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
|
|
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
|
|
|
|
tmp_cert="" tmp_key=""
|
|
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
|
|
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
|
|
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
|
|
|
|
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
|
|
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
|
|
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
|
|
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
|
|
|
|
chmod 0640 "$tmp_cert" "$tmp_key"
|
|
# The group the files had stays theirs: the guide gives the key to group 10997
|
|
# so the panel can read it for a full backup, and a run from root's cron would
|
|
# otherwise hand it to root's group (review of audit stage 5).
|
|
for f in fullchain privkey; do
|
|
if [ -e "$OUT_DIR/$f.pem" ]; then
|
|
tmp=$tmp_cert
|
|
[ "$f" = privkey ] && tmp=$tmp_key
|
|
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
|
|
fi
|
|
done
|
|
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
|
|
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
|
|
trap - EXIT
|
|
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|