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:
@@ -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
|
||||
|
||||
@@ -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]].
|
||||
|
||||
Reference in New Issue
Block a user