diff --git a/docs/progress.md b/docs/progress.md index be7eba7..119dda7 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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//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, проверено на `