image: the TLS key's group is the panel's alone, 10997 (audit I1)

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>
This commit is contained in:
mix
2026-10-17 22:03:10 +03:00
co-authored by Claude Opus 5.5
parent 17b7049311
commit 1bafd78364
11 changed files with 89 additions and 26 deletions
+10 -8
View File
@@ -336,13 +336,13 @@ ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
```
The panel reads them as its own user (Health shows the certificate, a full
backup carries both files), through the container's group 10998. certbot's
backup carries both files), through the container's group 10997. certbot's
documentation gives the way for a server that is not root, and certbot keeps
the key's group and mode at every renewal:
```sh
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chgrp 10997 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
```
@@ -363,7 +363,7 @@ docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
The fragment mounts certbot's directory into both nginx and SelfPost, at
`/etc/letsencrypt`; `./certs` holds two links to
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
container, not on the host — and the panel reads the key through group 10998,
container, not on the host — and the panel reads the key through group 10997,
as in the Apache case. Then start everything:
```sh
@@ -371,7 +371,7 @@ mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chgrp 10997 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
@@ -887,11 +887,13 @@ instance keeps running, what goes into the archive, and what happens on restore.
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below. The panel runs as its own user, not as
root, so **the private key in `certs/` has to be readable by it**: give the
file to the container's `selfpost` group, which has the fixed id 10998 and holds
both the panel and Postfix —
file to the container's `backupkey` group, which has the fixed id 10997 and
holds the panel alone - not Postfix, whose daemons face the internet (until
`2.14.4` the guide said group 10998, `selfpost`, which Postfix is in; a key
given to it then is to be given to 10997 now) —
```sh
chgrp 10998 certs/privkey.pem && chmod 640 certs/privkey.pem
chgrp 10997 certs/privkey.pem && chmod 640 certs/privkey.pem
```
— and have whatever renews the certificate do the same; certbot does it by
@@ -968,7 +970,7 @@ methods avoid it by stopping first.
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 10998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `certs/` private key | Yes (the panel needs it readable by group 10997; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No |
+1 -1
View File
@@ -755,7 +755,7 @@ the argument.
### Stage 2 — the key group (~2.5 h) → patch release, then the test server's `certs/`
- [ ] **I1.** `groupadd --system --gid 10997 backupkey; usermod -aG backupkey
- [x] **I1.** `groupadd --system --gid 10997 backupkey; usermod -aG backupkey
panel` in the Dockerfile; the guide's two `chgrp 10998` become `10997`
with a sentence on why; `handlers_backup.go:398-399` likewise; the e2e
stage gives the key to 10997 and `restore_test` asserts the `postfix` uid
+6
View File
@@ -266,6 +266,12 @@ operator can act on it.
`mail.log`, which `postfix` writes, through it the other way
([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM
keys are not in it (§ Mail path).
- **Group `backupkey` (gid 10997) has one member, `panel`.** The operator
gives the host's TLS private key to it so that a full backup carries the
key; Postfix serves a root-only copy and has no way to the original. Until
`2.14.4` the guide gave the key to `selfpost`, so every Postfix daemon -
an smtpd talking to the internet included - could read it (security audit
2026-10-17, I1; measured on the test server with `docker exec -u postfix`).
- **The container keeps `KILL`** (`deploy/docker-compose.yml`, `cap_add`;
every other capability not listed there is dropped). Without it root
cannot signal a process of another uid (kill(2), capabilities(7); measured