Phase 11: final security pass + acceptance (spec 7.6, 12.2-3)

Line-by-line audit of all 8 points of spec 7.6 against the code: full
compliance, no code changes required. Acceptance verified on the dev
server (image selfpost:p11): gofmt/vet/build/test green, docker build
ok, clean container start (all processes RUNNING, panel as non-root
uid 999, setup link + 0600 token, bogus token 404, unauth 303, healthz
200). Baseline v1.0 plan (phases 0->11) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 21:42:27 +03:00
parent 61f525e2d7
commit 65a420d230
+16 -1
View File
@@ -46,7 +46,9 @@
## Текущее состояние
- **Текущая фаза:** 10**закрыта** → следующая **Фаза 11** (финальный проход по безопасности) на **Opus**.
- **Текущая фаза:** 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.
- **Прежняя фаза:** 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]].
- **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`).
@@ -54,6 +56,18 @@
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
### Сделано в Фазе 11
- **Финальный проход по безопасности (ТЗ 7.6) — построчный аудит кода по всем 8 пунктам, все подтверждены:**
1. **Setup secret-link** ([setup.go](../internal/web/setup.go), [token.go](../internal/web/token.go)): токен 128 бит `crypto/rand` (`randomToken(16)`, base64url — 22 симв.), TTL 10 мин с ре-генерацией при истечении/рестарте, сравнение `subtle.ConstantTimeCompare`, **неудачи НЕ инвалидируют** токен, одноразовость через наличие строки admin (после успеха `/setup/*`→404), файл токена `0600`, пароль админа только bcrypt (`DefaultCost`).
2. **Серверная валидация** ([validate.go](../internal/web/validate.go), [app/validate.go](../internal/app/validate.go)): строгие whitelist для домена (DNS-форма, ≥2 меток, `[a-z0-9.-]`), логина (без `@`), localpart; **проверка принадлежности адреса домену до записи в конфиг** (`validateSenderAddress`); импорт-путь ([handlers_backup.go](../internal/web/handlers_backup.go)) — `MaxBytesReader` 1 MiB + `DisallowUnknownFields` + `normalizeDomain`/`validateDomain`, а `ImportApplication` валидирует login/адреса/пароль (`validateImportedPassword` — непустой, ≤1024, без управляющих).
3. **`os/exec` без shell** — все 5 сайтов фикс-argv, без `sh -c`: `saslpasswd2` (пароль по stdin, логин whitelisted argv), `db_dump`, `postqueue -p`, два `supervisorctl` (reload OpenDKIM/Postfix). Пользовательский ввод в аргументы команд не попадает вообще (кроме whitelisted логина).
4. **Санитизация записи в конфиги** ([opendkim.go](../internal/domain/opendkim.go) `assertConfigSafe`, [postfix.go](../internal/postfix/postfix.go) `assertMapSafe`) — hard-backstop против пробелов/переводов строк/разделителей перед записью KeyTable/SigningTable/sender-map, атомарная запись (temp+rename).
5. **Rate-limit логина** ([ratelimit.go](../internal/web/ratelimit.go), 10/15мин) + **отдельный на `/setup`** (10/мин).
6. **Сессии/cookie** ([session.go](../internal/web/session.go), [handlers_auth.go](../internal/web/handlers_auth.go)): токен 256 бит `crypto/rand`, cookie `HttpOnly`+`Secure`(дефолт true)+`SameSite=Lax` (Lax закрывает CSRF на POST-мутациях — все мутации POST, все GET read-only).
7. **Экранирование вывода** ([templates.go](../internal/web/templates.go)) — только `html/template`, ни одного `text/template`/`template.HTML`/`Safe*`-байпаса.
8. **Панель не под root** ([supervisord.conf](../build/supervisord.conf) `[program:panel] user=panel`; root только в entrypoint-бутстрапе и supervisord-PID1; межпользовательский доступ — через группу `selfpost`+setgid, не через привилегии).
- **Приёмка ТЗ 12.2–3 (на сервере selfpost.example.com, образ `selfpost:p11`):** `gofmt -l` пусто, `go vet ./...` чист, `go build ./...` ок, `go test ./...` — все пакеты зелёные; `docker build` ок; **чистый старт контейнера подтверждён вживую:** все долгоживущие процессы `RUNNING` (opendkim/panel/postfix/cert-reload/logrotate/crashexit; `postfix-reload` `STOPPED` — штатный on-demand one-shot), `restarts=0`; **панель под Uid/Gid 999 (`panel`, не root)** проверено по `/proc/<pid>/status`; setup-ссылка напечатана в лог с 128-бит токеном, `/data/setup-token` `0600 panel:panel`; live-проверки: битый setup-токен→**404** (маршрут скрыт), неаутентифицированный `/domains`→**303 `/login`** (auth-middleware), `/healthz`→**200**. Все критерии «Готово когда» Фазы 11 выполнены.
### Сделано в Фазе 10
- **Найден и закрыт пробел с Фазы 1:** `logrotate` был установлен пакетом в образ, но никогда не запускался (ни cron, ни supervisor-программы) — `mail.log` рос бы неограниченно. Добавлены `build/logrotate-mail.conf` (`/var/log/mail.log`, `daily`/`rotate 14`/`compress`/`delaycompress`/`copytruncate`) и `build/logrotate-loop.sh` (poll-цикл, дефолт 6ч; сам `logrotate` решает, пора ли ротировать, по `/var/lib/logrotate/status`) + supervisor-программа `[program:logrotate]` (по образцу `cert-reload`). **`copytruncate`, а не сигнал Postfix** — `maillog_file` пишет `postlogd`, который держит файл открытым весь свой жизненный цикл, и никакой демон ротацию не подхватывает; `copytruncate` избавляет от необходимости `postfix reload` на каждую ротацию ценой маленького окна потери нескольких строк лога при truncate — приемлемо для мониторингового лога. Проверено на сервере: `logrotate -f` реально ротирует (`mail.log` truncated, `mail.log.1` с прежним содержимым), процесс `logrotate` в `supervisorctl status` — RUNNING.
- **`deploy/docker-compose.yml`** (ТЗ 10, 10.5): единственный сервис `selfpost`, **Apache не контейнеризован** — предполагается, что он уже стоит на хосте (целевая аудитория — ТЗ 10.5), с фрагментом `deploy/apache/selfpost-vhost.conf` (host-vhost + certbot Apache-плагин, готовые PEM без шага извлечения). Образ — **фиксированный тег** `ghcr.io/mixeme/selfpost:X.Y.Z` (не `:latest`, ТЗ 10 п.10, обязательное условие для проверки версии при restore из Фазы 9). Порты 465/587 публикуются напрямую (почта мимо Apache), 8080 — только на `127.0.0.1` (Apache достаёт панель по localhost, наружу панель без TLS не видна). Hardening (ТЗ 10 п.6): `no-new-privileges`, `cap_drop: ALL` + точечный `cap_add` (`NET_BIND_SERVICE`/`CHOWN`/`SETUID`/`SETGID`/`DAC_OVERRIDE` — entrypoint по-прежнему стартует под root на доли секунды, чтобы починить владельца `/data` и права `selfpost`-группы, см. Фазы 3-4, поэтому `user: panel` и read-only rootfs целиком не подходят).
@@ -187,4 +201,5 @@
- **Фаза 8** (2026-07-14, Opus) — дифференцированные лимиты (rate limit уровень 2, ТЗ 7.4): journal-milter из чистого монитора стал отклонять письмо `4xx` (`RespTempFail` 451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт — `COUNT(DISTINCT queue_id)` в скользящем окне по `send_log`, применяется только при непустой IP-привязке (иначе только уровень 1). Строго **fail-open** на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутся `send_log` статусом `rejected` для UI. Store `internal/store/ratelimits.go` (таблица `rate_limits` уже была с Фазы 2 — миграции нет), панель-формы на домене и приложении с серверной валидацией IP/чисел (ТЗ 7.6.2), очистка лимитов при каскадном удалении. Юниты + контейнерный e2e (`p8`): реджект на обоих уровнях, unregistered-IP игнор, fail-open при остановке панели — зелёные. Все критерии «Готово когда» Фазы 8 выполнены.
- **Фаза 9** (2026-07-14, Opus) — бэкап/restore + экспорт/импорт домена (ТЗ 7.5, 11.6): полный `tar.gz`-бэкап всего `/data` (консистентный снимок SQLite через `VACUUM INTO`, DKIM-ключи, `sasldb2`, `manifest.json` с версией; без TLS-сертов/`tls/` и очереди Postfix) двумя путями — кнопка `POST /backup` и CLI `selfpost-backup` через `docker exec`; гварда версии `CheckRestore` до `store.Open` (несовпадение → отказ старта с указанием тега; совпадение → манифест потребляется, restore идёт обычным стартом без отдельной ветки). Экспорт/импорт домена: `DomainExport` (DKIM-ключ PKCS#1 PEM + приложения с **рабочими паролями**), секреты SASL читаются из `sasldb2` через `db_dump` (`userPassword` — плейнтекст), на импорте **ре-кеятся под локальный realm** через `saslpasswd2` → креды работают на инстансе с другим hostname без перевыпуска, DKIM DNS-запись не меняется. `db-util` добавлен в образ. Юниты + контейнерный e2e (экспорт↔импорт кросс-realm с проверкой SMTP-auth 235; CLI+кнопка бэкап; restore той же версии; отказ при несовпадении версии) зелёные.
- **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
- **Фаза 11** (2026-07-15, Opus) — финальный проход по безопасности и приёмка (ТЗ 7.6, 12.2–3): построчный аудит всех 8 пунктов раздела 7.6 против кода — **соответствие полное, правок кода не потребовалось** (setup-токен 128-бит/const-time/одноразовый, серверная валидация вкл. импорт-путь, `os/exec` фикс-argv без shell, санитизация записи в конфиги, rate-limit логина+setup, cookie `HttpOnly`/`Secure`/`SameSite=Lax`, только `html/template`, панель под `panel`≠root). Приёмка на сервере: `gofmt`/`vet`/`build`/`test` зелёные, `docker build` ок, **чистый старт контейнера `selfpost:p11`** (все процессы RUNNING, панель Uid 999, setup-ссылка+токен 0600, битый токен→404, unauth→303, healthz→200). Остаточная заметка (не дефект, спец-совместимо): rate-limit кеится по `RemoteAddr` (namеренно без XFF-парсинга) → за прокси лимитеры глобальны; для setup реальная защита — энтропия токена (ТЗ 7.6.1). **Базовый план v1.0 (0→11) завершён.**
- **Фаза 10** (2026-07-15, Sonnet) — деплой + документация (ТЗ 10): `deploy/docker-compose.yml` (Apache на хосте, не в контейнере — фиксированный тег образа, hardening `cap_drop ALL`+точечный `cap_add`, порт 8080 только на `127.0.0.1`) + альтернативные фрагменты nginx/Caddy/Traefik (каждый — валиден через `docker compose config`, относительные пути исправлены под правило «резолвятся от каталога первого `-f`-файла», списки `ports`/`volumes` через YAML-тег `!override`, иначе дополняются, а не заменяются). CI `.github/workflows/release.yml` (тег `vX.Y.Z` → сборка + push в `ghcr.io/mixeme/selfpost`, версия из тега в один `ldflags`+тег образа). **Найден и закрыт пробел с Фазы 1:** `logrotate` был установлен, но никогда не запускался — добавлены `logrotate-mail.conf`(`copytruncate`, т.к. `postlogd` держит `mail.log` открытым и сигнала на ротацию нет)+`logrotate-loop.sh`+supervisor-программа. README переписан (чеклист требований, DNS уровня сервера/домена, прогрев IP, бэкап vs экспорт/импорт домена, обоснование фиксированного тега, требования к машине). Проверено на сервере: `docker build`/`docker run` с новым logrotate — процессы RUNNING, принудительная ротация отработала; `docker compose config` зелёный для всех 4 вариантов. Все критерии «Готово когда» Фазы 10 выполнены.