deploy: add CAP_FOWNER/CAP_FSETID so entrypoint permission-fix works

Bringing up the production Apache stack for real surfaced a latent bug
in the Phase 10 hardening: cap_drop: ALL with only NET_BIND_SERVICE/
CHOWN/SETUID/SETGID/DAC_OVERRIDE left the root startup phase unable to
chmod the /data dirs it had just chowned to the panel user (needs
CAP_FOWNER) or set their setgid bit (needs CAP_FSETID). The container
crash-looped on "chmod: Operation not permitted". Phase 10 never caught
this because its compose up hit a port conflict before full boot.

Add FOWNER and FSETID to cap_add and document what each capability is
for. Verified: container now starts clean under the hardened compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 22:18:44 +03:00
parent 158b5323d3
commit 88f9d33e8d
2 changed files with 17 additions and 3 deletions
+15 -3
View File
@@ -65,9 +65,19 @@ services:
# `user: panel` or a fully read-only rootfs without breaking that startup
# self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the
# small set Postfix/OpenDKIM genuinely need (binding <1024, chown/setuid
# during startup, and DAC overrides for cross-user file access within the
# shared group).
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass);
# - FSETID — set the setgid bit (2750) on the shared /data dirs
# when the process gid differs from the dir's group;
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
# `panel` user; Postfix switches to its own users;
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
# FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container
# crash-loops on start.
security_opt:
- no-new-privileges:true
cap_drop:
@@ -75,6 +85,8 @@ services:
cap_add:
- NET_BIND_SERVICE
- CHOWN
- FOWNER
- FSETID
- SETUID
- SETGID
- DAC_OVERRIDE
+2
View File
@@ -48,6 +48,8 @@
- **Текущая фаза:** 11 ✅ **закрыта****базовый линейный план 0→11 (v1.0) полностью выполнен**. Дальше — только опциональные фазы O1+ линии 2.x.x (входящий релей), которые **не входят в v1.0** и требуют явного согласования (ТЗ 12.6, раздел 3). Модель для будущих фаз — по таблице.
- **Итог Фазы 11 (аудит 7.6):** сквозная проверка всех 8 пунктов раздела 7.6 против кода — **соответствие полное, изменений кода не потребовалось**. Единственная остаточная заметка (не дефект, спец-совместимо): rate-limit логина/`/setup` кеится по `RemoteAddr` (`clientIP` намеренно НЕ парсит `X-Forwarded-For`, чтобы его нельзя было подделать — [web.go:147](../internal/web/web.go)). За обратным прокси (дефолтный Apache-деплой) это адрес прокси, т.е. лимитеры фактически глобальны — осознанный и задокументированный компромисс: для setup-токена реальная защита — 128-бит энтропии (ТЗ 7.6.1 прямо называет rate-limit defense-in-depth, не основной мерой), для логина простой per-IP счётчик достаточен по ТЗ 7.6.5. Парсинг XFF был бы хуже (позволил бы обойти лимит подделкой заголовка). Если оператору нужен настоящий per-client лимит за прокси — это будущее улучшение через trusted-proxy XFF, а не правка v1.0.
- **Боевой деплой поднят (2026-07-15)** на `selfpost.mixfed.ru` (81.30.105.2) по дефолтному сценарию ТЗ 10 (**Apache на хосте**): образ `ghcr.io/mixeme/selfpost:1.0.0` (собран локально, `VERSION=1.0.0`), compose в `/root/selfpost-deploy/` (`./data`, `./certs`, `SUBMISSION_ENABLE=true`), реальный сертификат Let's Encrypt (`certbot --apache`, автопродление + `--deploy-hook`, копирующий PEM в `./certs` с `privkey 0640 gid=104` = контейнерный `postfix`), Apache vhost — reverse-proxy панели `443→127.0.0.1:8080` + `80→443` редирект + security-заголовки (HSTS/nosniff/X-Frame-Options — из бэклога A2). Проверено: панель `https://selfpost.mixfed.ru` (healthz 200, http→301), **SMTPS 465 и submission 587 отдают валидный LE-серт** (`Verify return code: 0`), контейнер `restarts=0`, все процессы RUNNING.
- **Найден и исправлен реальный баг деплой-артефакта Фазы 10 (боевой прогон вскрыл):** `deploy/docker-compose.yml` с `cap_drop: ALL` не давал entrypoint'у нужных `CAP_FOWNER`/`CAP_FSETID` — root не мог `chmod`/setgid уже перечоуненные на `panel` каталоги `/data``chmod: Operation not permitted` и **crash-loop** контейнера. В Фазе 10 это не поймали, т.к. тогдашний `docker compose up` упёрся в конфликт порта 465 (контейнер `p6`) и не догрузился. Фикс: добавлены `FOWNER`+`FSETID` в `cap_add` (+ расширен комментарий, какая capability зачем). Проверено — контейнер стартует чисто.
- **Прежняя фаза:** 10 ✅ закрыта.
- **Ключевая находка Фазы 9 (SASL-секреты обратимы, как и предвидело ТЗ 7.5.Б):** `sasldb2` (Berkeley DB, db5.3) хранит пароль приложения как **плейнтекст** в свойстве `userPassword` — подтверждено на сервере. Значит экспорт домена читает его через `db_dump` и на импорте **перезаписывает под локальный realm** через `saslpasswd2` (плейнтекст realm-независим) → креды работают на другом хосте с ДРУГИМ hostname/realm без перевыпуска. Полный бэкап копирует `sasldb2` **побитово** (keyed по исходному realm), поэтому restore обязан идти на **тот же hostname** (миграция всей машины). `db-util` (даёт `db_dump`) добавлен явной зависимостью в Dockerfile. Файл `/data/setup-token` содержит **полный URL**, а не голый токен (для e2e: `TOKEN=${FULL##*/}`).
- **Ключевая находка Фазы 6 (исправлена):** go-milter хранит имена макросов **как их шлёт Postfix** — многосимвольные имена приходят в фигурных скобках (`{auth_authen}`, `{client_addr}`), односимвольные — голыми (`i`). Спайк Фазы 0 без SASL этого не увидел (`auth_authen` был пуст «и так»). Первый прогон в контейнере дал пустой `app_login`; фикс — brace-толерантный `macro(m,name)` (пробует голый ключ, затем `{name}`). Зафиксировано в памяти [[milter-implementation-facts]].