diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f317767 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/). + +## [Unreleased] + +- ci: disable provenance attestation on release image push, so the ghcr.io + manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). + +## [0.1.0] - 2026-07-15 + +Initial feature-complete implementation of the v1.0 specification (phases 0-11 +of `docs/implementation-plan.md`). + +### Added + +- Panel (Go, single static binary) with SQLite persistence, one-time + crypto-random setup link, bcrypt admin auth, session cookies. +- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and + OpenDKIM KeyTable/SigningTable regeneration + privilege-safe reload. +- Application (sender identity) management: SASL credentials via `sasldb2`, + `smtpd_sender_login_maps` enforcing sender/domain ownership, no open relay. +- Full Postfix relay config generated from env at container start: SMTPS 465, + optional STARTTLS submission 587, SASL auth, TLS for outbound delivery, + anvil-based rate limiting (level 1). +- Journal milter (pure Go, `go-milter`) recording every send to `send_log`; + fail-open by design so a milter fault never blocks mail. +- Monitoring UI: send log, Postfix queue, and mail.log tail, all + HTMX-polling, HTML-escaped. +- Per-domain/per-application sending rate limit (level 2), enforced in the + journal milter at `MAIL FROM`, fail-open on the limiter's own errors. +- Full backup/restore (`tar.gz` of `/data`, consistent SQLite snapshot via + `VACUUM INTO`) with a version guard that refuses to start on a + manifest/binary version mismatch. Per-domain export/import for moving a + single domain between hosts without re-issuing DNS records. +- Deployment: Docker image + compose, reverse-proxy fragments for Apache + (default), nginx, Caddy, and Traefik; CI workflow publishing tagged, + multi-arch images to `ghcr.io` on `vX.Y.Z` tags. +- Security pass against spec 7.6 (exec safety, config-write sanitization, + server-side validation, rate limiting, session/cookie hardening, output + escaping, non-root panel) — full compliance, no code changes required. +- Live production deployment on `selfpost.example.com` with a real Let's + Encrypt certificate; end-to-end delivery confirmed (DKIM pass, SPF pass). diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 23c9632..9935a6a 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,213 +1,24 @@ # План реализации: SelfPost -**Статус:** черновик для согласования (см. ТЗ, раздел 12, п. 7 — план утверждается до начала кодирования). +**Статус:** базовый линейный план (фазы 0→11, v1.0) выполнен и принят — см. +[progress.md](progress.md) (текущее состояние) и [CHANGELOG.md](../CHANGELOG.md) +(история релизов). Он здесь не повторяется. + +Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для +согласования и опциональная линия 2.x.x. + **Основа:** [specification.md](specification.md) v1.0. -Принцип движения (ТЗ 12.4): сначала минимальный работающий скелет (образ собирается, три процесса стартуют, пустая панель с логином), затем итеративное наращивание. Требования безопасности раздела 7.6 закладываются сразу в соответствующих эндпоинтах, а не «потом» (ТЗ 12.5). Коммиты — только по явной команде (ТЗ 12.1). - ---- - -## Зафиксированные технические решения (до кодирования) - -Эти выборы предлагается утвердить вместе с планом — они влияют на всю структуру. - -| Решение | Выбор | Обоснование | -|---|---|---| -| Module path | `codeberg.org/mix/selfpost` | основной репозиторий — Codeberg (ТЗ 11.7) | -| Базовый образ | `debian:bookworm-slim` | зафиксировано ТЗ 4 | -| SQLite-драйвер | `modernc.org/sqlite` (чистый Go, BSD-3) | сохраняет статический бинарник без cgo; лицензия permissive (ТЗ 7.1, 12.8) | -| bcrypt | `golang.org/x/crypto/bcrypt` (BSD) | хэш пароля админа (ТЗ 7.6.1) | -| milter | `github.com/emersion/go-milter` (MIT) | единственный жизнеспособный вариант; **требует ранней проверки совместимости** (ТЗ 7.3, риск) | -| Front-end | `html/template` + вендоренный HTMX, без npm/SPA | зафиксировано ТЗ 7.1 | -| Версия в бинарнике | `-ldflags "-X main.version=..."` | для проверки совместимости бэкапа (ТЗ 7.5.А, 11.1) | - -Каждая внешняя зависимость обоснована и permissive/GPL-совместима (ТЗ 12.8). Больше сторонних зависимостей без согласования не вводим. - -**Предлагаемая структура репозитория:** -``` -cmd/panel/ — main панели (HTTP + milter + log-tailer + rate-limit) -cmd/selfpost-backup/ — CLI-утилита бэкапа (ТЗ 11.6) -internal/store/ — SQLite: схема, миграции, запросы -internal/domain/ — доменная модель, DKIM, OpenDKIM KeyTable/SigningTable -internal/app/ — приложения, SASL (sasldb2), sender_login_maps -internal/postfix/ — генерация конфигов, reload, escaping -internal/milter/ — journal-milter + rate-limit level 2 -internal/logtail/ — хвост mail.log, обновление статусов -internal/web/ — хендлеры, сессии, setup-link, middleware -internal/backup/ — полный бэкап/restore, экспорт/импорт домена -web/templates/ — html/template -web/static/ — вендоренный htmx.min.js -build/ — Dockerfile, supervisord.conf, стартовые скрипты, шаблоны конфигов, logrotate -deploy/ — docker-compose.yml (Apache) + фрагменты nginx/Caddy/Traefik -``` - -Тестовый стенд: `ssh root@selfpost.example.com` (PTR настроен, порт 25 предполагается открытым) — [dev/env.txt](../dev/env.txt). Секреты разработки — только в `dev/` (в `.gitignore`). - ---- - -## Фаза 0 — Каркас проекта и де-риск milter'а - -**Цель:** структура готова, критический риск проверен до того, как в него вложено много кода. - -- `go mod init`, структура каталогов, пустые пакеты, `main.version`. -- `LICENSE` — полный текст AGPL-3.0 (ТЗ 11.9). -- Скелет `README.md`, Makefile/скрипты сборки с ldflags-версией. -- **Спайк по milter (де-риск, ТЗ 7.3):** на тестовом сервере поднять голый Postfix из Debian bookworm + минимальный go-milter, убедиться, что версия протокола совместима и что milter читает `From`/`To`/`Subject`/SASL-login на этапе EOH. Проверить поведение `milter_default_action` при падении/таймауте milter'а (fail-open). Результат спайка — краткая заметка в `dev/`. - -**Готово, когда:** `go build`/`go vet` проходят на пустом каркасе; спайк подтвердил работоспособность go-milter с Postfix из образа (или зафиксировал альтернативу). - ---- - -## Фаза 1 — Образ + supervisord + три процесса (минимальный скелет) - -**Цель:** `docker build` собирает образ, контейнер стартует, три процесса живы, пустая панель отвечает на `:8080`. - -- `Dockerfile` (bookworm-slim): postfix, opendkim, cyrus-sasl (`sasldb2`, `sasl2-bin`), supervisor, logrotate, ca-certificates; сборка Go-бинарников многостадийно. -- `supervisord.conf` с `priority=`: OpenDKIM → panel → (обёртка) Postfix; при неперезапускаемом падении любого процесса контейнер завершается (ТЗ 4). -- **Стартовая обёртка Postfix**: опрос готовности milter-сокетов OpenDKIM и journal-milter (`test -S`, интервал, таймаут ~30с), запуск `postfix start-fg` только после готовности обоих; при таймауте — выход с ошибкой (ТЗ 4). -- Панель: HTTP `:8080` с заглушкой, **stub-listener journal-milter** (создаёт сокет, чтобы обёртка проходила), stub log-tailer. -- Непривилегированный пользователь для панели (ТЗ 7.6.8). - -**Готово, когда:** образ собирается, `docker run` даёт три живых процесса, панель отдаёт заглушку, обёртка корректно ждёт сокеты. - ---- - -## Фаза 2 — Персистентность, инициализация админа, вход - -**Цель:** безопасный вход в панель одного администратора. - -- SQLite-схема + миграции: домены, приложения, админ, `send_log`, лимиты, настройки, флаг «настройка завершена»/setup-токен (ТЗ 9). Единый корень `/data` (ТЗ 7.5.А, 9). -- **Setup secret-link (ТЗ 7.6.1):** токен ≥128 бит из `crypto/rand`; вывод в stdout + `/data/setup-token`; TTL 10 мин с перегенерацией; rate-limit маршрута; `subtle.ConstantTimeCompare`; неудачи НЕ инвалидируют токен; одноразовая форма создания админа; инвалидация навсегда после успеха (маршрут → 404). -- bcrypt-хэш пароля админа; никакого plaintext. -- Логин, сессии (крипто-случайный токен, cookie `HttpOnly`/`Secure`/`SameSite`), rate-limit логина (ТЗ 7.6.5–6). -- Базовый layout `html/template`, вендоренный HTMX, middleware аутентификации. - -**Готово, когда:** первый запуск печатает setup-ссылку, админ создаётся один раз, повторный `/setup` → 404, вход/выход работают, токены сессий безопасны. - ---- - -## Фаза 3 — Домены + OpenDKIM - -**Цель:** добавление/удаление домена с генерацией DKIM. - -- Добавить/список/удалить домен; при удалении — предупреждение о каскадном удалении приложений (ТЗ 7.2.4). -- Генерация DKIM-ключа + селектор per-domain (дефолт из `DKIM_SELECTOR_DEFAULT`); ключи в `/data/...` переживают рестарт (ТЗ 6, 9). -- Поддержка `KeyTable`/`SigningTable`, reload OpenDKIM. -- Показ DKIM TXT-записи per-domain (ТЗ 7.2.10). -- **Безопасная запись конфигов** с санитизацией/экранированием (ТЗ 7.6.4); `os/exec` без shell и без интерполяции ввода (ТЗ 7.6.3); строгая валидация имени домена (whitelist символов, ТЗ 7.6.2). - -**Готово, когда:** домен добавляется/удаляется, DKIM-ключ генерируется и переживает рестарт, TXT-запись показывается, OpenDKIM перечитывает таблицы. - ---- - -## Фаза 4 — Приложения + SASL + привязка к домену - -**Цель:** ядро доменной модели (ТЗ 4.1, 5.1). - -- Создание учётки в `sasldb2` (эквивалент `saslpasswd2`), генерация сильного пароля, показ **один раз** (ТЗ 7.6.1). -- Режим адресов на уровне приложения: «любой адрес домена» (wildcard `@example.com`) либо «список» (запись на каждый адрес); серверная валидация принадлежности каждого адреса домену приложения (ТЗ 7.6.2). -- Поддержка `smtpd_sender_login_maps` соответственно режиму; many-to-one. -- Список приложений домена с текущим режимом; редактирование режима; удаление приложения (только его записи); перевыпуск пароля (режим сохраняется) — с `postfix reload` (ТЗ 7.2.5–9). - -**Готово, когда:** приложения создаются/редактируются/удаляются, карта привязки корректно пересобирается, пароль показывается один раз. - ---- - -## Фаза 5 — Полная конфигурация Postfix (исходящий релей) - -**Цель:** реальная отправка почты с аутентификацией и DKIM. - -- `master.cf`: `smtps` 465 (wrapper TLS, `smtpd_tls_wrappermode=yes`) как основной; `submission` 587 (STARTTLS, `smtpd_tls_auth_only=yes`) — опционально, тем же способом (ТЗ 5, 5.1). -- SASL (`smtpd_sasl_auth_enable`), TLS cert/key из `TLS_CERT_FILE`/`TLS_KEY_FILE` (read-only mount), периодический `postfix reload` для обновления сертификата (ТЗ 5.2). -- `smtpd_recipient_restrictions` (`permit_sasl_authenticated`, `reject_unauth_destination`), `smtpd_sender_restrictions` + `reject_sender_login_mismatch`; **никакого open relay** (ТЗ 5.4, 5.1.3). -- Исходящая доставка напрямую (MX-lookup, `smtp_tls_security_level=may`). -- Уровень 1 rate-limit (`anvil`: `smtpd_client_message_rate_limit`, `anvil_rate_time_unit`) из env (ТЗ 5.5, 7.4). -- Milter-цепочка `smtpd_milters`: OpenDKIM + journal-milter; `milter_default_action` для journal — **fail-open** (ТЗ 7.3). - -**Готово, когда:** с тестового сервера письмо реально уходит получателю, подписано DKIM, аутентификация обязательна, привязка отправителя работает, неаутентифицированный релей отклоняется. - ---- - -## Фаза 6 — Journal-milter и обновление статусов (Send Log, бэкенд) - -**Цель:** структурированный журнал отправки. **Самый чувствительный узел (ТЗ 7.3) — повышенное внимание к тестам.** - -- Реализация journal-milter на go-milter: на EOH читает домен, SASL-login, From, To, Subject; создаёт запись `send_log` со статусом «в очереди», привязка к queue-id. -- Отдельная запись на пару (queue-id, получатель) (ТЗ 7.3.3). -- Log-tailer: горутина хвостит `mail.log`, парсит `sent`/`bounced`/`deferred` по queue-id, обновляет записи. -- Retention: фоновая очистка старше `SEND_LOG_RETENTION_DAYS` (дефолт 90). -- **Тесты отказа:** поведение при падении/таймауте milter'а — приём почты не блокируется (fail-open проверяется явно). - -**Готово, когда:** отправленное письмо порождает запись, статус доходит до финального, ретеншн чистит старое; убийство milter'а не роняет приём почты. - ---- - -## Фаза 7 — UI мониторинга (журнал, очередь, лог) - -**Цель:** экраны наблюдения с автообновлением. - -- Журнал отправки: таблица (время, домен, приложение, From, To, Subject, статус), **серверные фильтры** по домену и приложению (`WHERE`), пагинация, HTMX-polling; экранирование вывода (ТЗ 7.3, 7.6.7). -- Просмотр очереди (`postqueue -p` в читаемом виде), HTMX-polling (ТЗ 7.2.11). -- Хвост `mail.log`, HTMX-polling (ТЗ 7.2.13). -- Кнопка ручного reload (ТЗ 7.2.12). -- Fragment-эндпоинты возвращают HTML-куски, не JSON (ТЗ 7.1). - -**Готово, когда:** три экрана работают, фильтры серверные, автообновление через polling, вывод экранирован. - ---- - -## Фаза 8 — Дифференцированные лимиты (rate limit уровень 2) - -**Цель:** лимиты на домен/приложение поверх backstop уровня 1. - -- Настройка в панели: ожидаемые IP + лимит (N писем за окно) на уровне домена и приложения; оба необязательны; пустая IP-привязка допустима (ТЗ 7.4). -- Проверка в milter: ключ — client IP; счётчик из SQLite (переиспользует данные журнала); превышение → отказ 4xx + опциональная запись «отклонено по лимиту»; **fail-open** при недоступности milter'а (ТЗ 7.4). - -**Готово, когда:** лимит на домен/приложение срабатывает (4xx), пустая привязка не ломает отправку, при падении milter'а остаётся уровень 1. - ---- - -## Фаза 9 — Бэкап/восстановление и экспорт/импорт домена - -**Цель:** переезд «прост как архив» + перенос одного домена. - -- **Полный бэкап (ТЗ 7.5.А):** архив всего `/data` (SQLite через `VACUUM INTO`/Backup API для консистентного снимка на живом контейнере, DKIM-ключи, `sasldb2`, `manifest.json` с версией). Без TLS-сертификатов и очереди Postfix. -- Кнопка в панели + **CLI `selfpost-backup`** через `docker exec` (ТЗ 11.6). -- **Restore:** сверка версии манифеста с версией бинарника; при несовпадении — отказ с понятным сообщением (какой тег образа нужен); состояние регенерируется из SQLite тем же путём, что при обычном старте, без отдельной ветки restore (ТЗ 7.5.А). -- **Экспорт/импорт домена (ТЗ 7.5.Б):** файл с именем домена, DKIM-ключом/селектором, приложениями и их записями `sasldb2` (рабочие пароли без перевыпуска); импорт восстанавливает домен без смены DNS. Файл помечается как секрет. - -**Готово, когда:** бэкап скачивается (панель и CLI), restore на чистом контейнере той же версии поднимает всё без пере-настройки; несовпадение версии отклоняется; экспорт/импорт домена переносит рабочие креды. - ---- - -## Фаза 10 — Деплой и документация - -**Цель:** поставка и эксплуатационная документация. - -- `docker-compose.yml`: **Apache** как основной reverse-proxy «из коробки», **фиксированный тег версии** (не `:latest`), bind mount `./data:/data` + read-only mount сертификатов, hardening (non-root, `cap_drop`, ограничение ФС) (ТЗ 10, 9). -- Альтернативные фрагменты: nginx, Caddy (проверить актуальный путь хранилища), Traefik (шаг извлечения PEM) (ТЗ 10.3). -- `logrotate` для `mail.log` в образе (ежедневно, 7–14 файлов, ТЗ 9). -- `README.md`: требования к площадке (чеклист), настройка DNS per-domain (SPF/DKIM/DMARC + PTR), прогрев IP, процедуры бэкапа/restore и экспорта/импорта (оба файла — секреты), фиксированный тег, требования к машине, лицензия AGPL-3.0 и её смысл, ссылки Codeberg (основной)/GitHub (зеркало) (ТЗ 10, 11.7). - -**Готово, когда:** `docker compose up` за Apache поднимает рабочий стенд; README покрывает установку, DNS, бэкап/миграцию, лицензию. - ---- - -## Фаза 11 — Финальный проход по безопасности и приёмка - -**Цель:** соответствие разделу 7.6 и критериям ТЗ 12. - -- Аудит: панель не под root и права ФС; серверная валидация ввода; `os/exec` без shell; экранирование всего вывода из логов/очереди; санитизация записи в конфиги; флаги cookie; rate-limits (setup, логин). -- `go build`, `go vet`, `go test`; `docker build`; проверка старта контейнера (ТЗ 12.2–3). -- Опционально: `/security-review` по диффу ветки. - -**Готово, когда:** все пункты 7.6 подтверждены, сборка/вет/тесты/образ зелёные, контейнер стартует чисто. - --- ## Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0) -Базовый план 0→11 выполнен и **соответствует ТЗ** (все обязательные пункты 7.6 подтверждены аудитом Фазы 11). Ниже — то, что **выходит за букву ТЗ**, но заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого **не является дефектом соответствия**; это осознанные компромиссы и потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», принимается пользователем. +Базовый план 0→11 выполнен и **соответствует ТЗ** (все обязательные пункты 7.6 +подтверждены аудитом Фазы 11). Ниже — то, что **выходит за букву ТЗ**, но +заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого +**не является дефектом соответствия**; это осознанные компромиссы и +потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в +2.x / оставляем как есть», принимается пользователем. ### A. Безопасность — hardening сверх обязательного 7.6 @@ -227,7 +38,7 @@ deploy/ — docker-compose.yml (Apache) + фрагменты nginx ### C. CI и тесты 10. **CI не гоняет `go test`.** [.github/workflows/release.yml](../.github/workflows/release.yml) на теге только собирает и пушит образ; `go vet` выполняется внутри Dockerfile-сборки, но **юнит-тесты в CI не запускаются** — вся тестовая проверка идёт вручную на dev-сервере. **Рекомендация:** добавить обычный workflow на push/PR (`go vet` + `go test ./...` + `gofmt -l`), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу. -11. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в progress.md, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI. +11. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI. ### D. Указатель на объём 2.x @@ -278,17 +89,4 @@ deploy/ — docker-compose.yml (Apache) + фрагменты nginx **Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции. ---- - -## Ключевые риски и как их снимаем - -1. **Go-milter (ТЗ 7.3)** — самый рискованный узел (баг ломает не только журнал, а сам релей). Снимается ранним спайком (Фаза 0) и явными тестами отказа (Фаза 6), fail-open через `milter_default_action`. -2. **Холодный старт milter-сокетов** — обёртка Postfix ждёт готовности сокетов (Фаза 1). -3. **Версионирование бэкапа** — фиксированный тег образа + проверка манифеста (Фазы 9–10). -4. **Открытый релей / утечка кред** — привязка `sender_login_maps` + `reject_sender_login_mismatch`, обязательный SASL, отсутствие `mynetworks`-авторизации (Фаза 5). - -## Порядок и зависимости - -Фазы линейны по зависимостям (0→1→2→3→4→5→6→7→8→9→10→11), кроме спайка milter (0), который де-рискует Фазу 6 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере. - -Опциональная **Фаза O1 (входящий релей)** — вне этого линейного базиса: не является частью v1.0, зависит только от готового исходящего тракта (Фазы 1–5) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования. +**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования. diff --git a/docs/progress.md b/docs/progress.md index dabdc63..14bacdd 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,31 +1,18 @@ # Прогресс реализации SelfPost -Живой трекер фаз. **Переживает `/clear`** — читается первым при возобновлении работы. -План фаз: [implementation-plan.md](implementation-plan.md). ТЗ: [specification.md](specification.md). +Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы. +План (открытые вопросы + опциональные фазы): [implementation-plan.md](implementation-plan.md). +ТЗ: [specification.md](specification.md). История релизов: [CHANGELOG.md](../CHANGELOG.md). +История сделанного по фазам (0→11, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. ## Как возобновить после сброса контекста -1. Прочитать этот файл (текущая фаза, статус, что сделано, что дальше). -2. Прочитать соответствующую фазу в `implementation-plan.md`. +1. Прочитать этот файл (текущее состояние, что дальше). +2. Открыть `implementation-plan.md` — там нерешённые вопросы и опциональная линия 2.x.x (Фаза O1+). 3. При необходимости — детали в `specification.md`. 4. Продолжить с пункта «Следующий шаг». -## Рекомендуемая модель по фазам - -| Фаза | Модель | Почему | -|---|---|---| -| 0 — каркас + спайк milter | **Opus** | архитектура + главный технический риск (7.3) | -| 1 — Docker/supervisord/обёртка | **Opus** | тонкая логика холодного старта сокетов | -| 2 — SQLite/setup-link/auth | **Opus** | безопасность 7.6 (крипто-токен, сессии, bcrypt) | -| 3 — домены + OpenDKIM | **Opus** | генерация конфигов + exec-safety (7.6.3–4) | -| 4 — приложения + SASL + sender_login_maps | **Opus** | риск open relay / привязки отправителя | -| 5 — полный Postfix | **Opus** | самый чувствительный тракт доставки | -| 6 — journal-milter | **Opus** | наивысший риск (баг ломает релей) | -| 7 — UI мониторинга | **Sonnet** | шаблоны/CRUD, рутинно | -| 8 — rate limit L2 | **Opus** | логика лимитов в milter | -| 9 — бэкап/restore/экспорт | **Opus** | целостность данных, версионирование | -| 10 — деплой + docs | **Sonnet** | compose-файлы и документация | -| 11 — security-проход | **Opus** | аудит соответствия 7.6 | +## Модель по типу работы Правило: безопасность / инфра / риск-критичное → **Opus**; UI / документация / бойлерплейт → **Sonnet**; тривиальная механика → **Haiku**. @@ -33,175 +20,24 @@ Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude Opus 4.8 `. -## Протокол закрытия фазы +На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml). -Перед `/clear` в конце каждой фазы Claude: -1. Обновляет этот файл: статус фазы → ✅, заполняет «Сделано» и «Следующий шаг». -2. Проверяет критерии «Готово, когда…» из плана. -3. Пишет одну строку в журнал ниже. -4. Делает финальный коммит фазы. -5. Явно говорит: «Фаза N закрыта — можно `/clear`, следующая фаза N+1 на модели X». +## Протокол закрытия фазы/крупного шага + +Перед `/clear` в конце каждого законченного шага Claude: +1. Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше. +2. Проверяет применимые критерии «Готово, когда…». +3. Дописывает `CHANGELOG.md` под `[Unreleased]`. +4. Делает финальный коммит шага. --- ## Текущее состояние -- **Текущая фаза:** 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.example.com` (203.0.113.10) по дефолтному сценарию ТЗ 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.example.com` (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]]. -- **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`). -- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна. -- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `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 целиком не подходят). -- **Альтернативные фрагменты reverse-proxy** (ТЗ 10.3, каждый — отдельный `docker compose -f docker-compose.yml -f /docker-compose..yml`): `deploy/nginx/` (контейнеризованный nginx + certbot-сайдкар, PEM тем же bind-mount способом, что и Apache), `deploy/caddy/` (автоматический ACME без доп. контейнеров; путь хранения PEM у Caddy включает имя ACME CA как сегмент пути — **явно помечено как нестабильная внутренняя деталь**, требует проверки под конкретную версию, как и просило ТЗ), `deploy/traefik/` (сертификаты внутри `acme.json`, `deploy/traefik/extract-cert.sh` — скрипт извлечения PEM через `jq`). **Важная находка при проверке:** Docker Compose резолвит все относительные пути в объединяемых файлах относительно каталога **первого** `-f`-файла (т.е. `deploy/`), а не каталога самого фрагмента — путь во фрагментах поэтому не `./nginx.conf.example`, а `./nginx/nginx.conf.example` и т.п.; проверено `docker compose config` для всех трёх фрагментов на сервере (пути резолвились верно только после этого фикса). Списки `ports`/`volumes` в фрагментах используют YAML-тег `!override` (Docker Compose merge-тег) — без него списки **дополняются**, а не заменяются, и тогда порт 8080 из базового файла остался бы опубликован рядом с портами прокси; подтверждено на сервере (`docker compose config` показывает итоговый `selfpost` только с портами/томами фрагмента, не суммой). -- **CI-публикация образа** (`.github/workflows/release.yml`, ТЗ 10.1): триггер — push тега `vX.Y.Z` (обычные коммиты ничего не публикуют); версия выводится из тега один раз (`GITHUB_REF_NAME` без `v`) и идёт **и** в `-ldflags` бинарника, **и** в тег образа — структурно исключает рассинхрон, на который опирается проверка версии при restore (Фаза 9). Публикация в `ghcr.io/mixeme/selfpost` (обоснование ТЗ 10.1: `ghcr` бесплатен и без анонимного rate-limit, в отличие от Docker Hub). Мультиплатформенная сборка (`linux/amd64`+`linux/arm64`) через `buildx`/`qemu`. -- **README.md переписан** (ТЗ 10 п.7-11): чеклист «Requirements», Quick start со ссылкой на `deploy/`, раздел «Reverse proxy» (таблица всех 4 вариантов + обоснование дефолта Apache), «DNS setup» (явное разделение уровня сервера/уровня домена — PTR один раз vs SPF/DKIM/DMARC на каждый добавленный домен), «IP warmup», «Backup, restore, and moving a single domain» (полный бэкап vs экспорт/импорт домена — разница в сценарии переноса, оба файла — секреты), «Fixed image tag» (связь с проверкой версии restore), «Machine requirements» (1 vCPU, 512МБ-1ГБ, 8-10ГБ диска, рекомендация swap). -- **Проверено на сервере** (selfpost.example.com): `gofmt`/`vet`/`test` зелёные (Go-код фазы не менялся); `docker build` образа с новым logrotate-путём — ок (`selfpost:p10`); контейнер стартует, все процессы (включая новый `logrotate`) RUNNING; ручная принудительная ротация мониторингового лога отработала; `docker compose config` — валиден для базового файла и всех трёх альтернативных фрагментов (nginx/caddy/traefik), после чего смёрженный `selfpost`-сервис содержит ожидаемые порты/тома/env, а не сумму базового+фрагмента; сборка `docker compose up` базового файла реально создаёт контейнер и сеть (сама попытка упёрлась только в конфликт порта 465 с посторонним контейнером `p6`, оставшимся от более ранней фазы, — не дефект текущей конфигурации). CI-workflow не запускался (нет прав пушить теги в этой сессии) — синтаксис YAML проверен парсером. - -### Сделано в Фазе 9 -- **Полный бэкап** (`internal/backup/backup.go`, ТЗ 7.5.А): `Create(w, Params)` пишет `tar.gz` всего `/data` — **консистентный снимок SQLite через `VACUUM INTO`** во временный файл (не побайтовое копирование живого WAL-файла), DKIM-ключи, `sasldb2`, карта Postfix, + `manifest.json` (`format`/`version`/`created_at`). Имена в архиве — относительно `/data`, так что распаковка в bind-mount восстанавливает состояние на месте. **Исключаются**: живой `selfpost.db`(+`-wal`/`-shm`/`-journal`, заменён снимком под тем же именем), `setup-token`, стейл-`manifest.json`, и каталог **`tls/`** — сертификаты это зона reverse-proxy (ТЗ 7.5.А); исключение держит гарантию даже если оператор положил серты в `/data/tls`. Каталоги-записи сохраняются (моды/пустые). Очередь Postfix не входит (ТЗ). -- **Гварда версии при restore** (`CheckRestore`, вызывается в `run()` **до** `store.Open` в `cmd/panel/main.go`): если в `/data` лежит `manifest.json` (значит бэкап распакован), его версия обязана совпасть с версией бинарника, иначе панель **отказывается стартовать** с сообщением, каким тегом образа восстанавливать (`selfpost:`); при совпадении манифест **потребляется** (удаляется) — гвардит только первый старт после restore и не блокирует обычный in-place апгрейд образа; отсутствие манифеста = обычный старт. Restore — не отдельная ветка кода: состояние (Postfix/OpenDKIM) регенерируется из восстановленного SQLite тем же путём, что при любом старте. -- **CLI `selfpost-backup`** (`cmd/selfpost-backup/main.go`, ТЗ 11.6): по умолчанию пишет `tar.gz` в **stdout** (`docker exec selfpost-backup > backup.tar.gz`), флаг `-o` — в файл (0600). Читает `SELFPOST_DATA_DIR`/`SELFPOST_DB_PATH` из env, зовёт `backup.Create`. Эквивалент кнопки в панели. -- **Кнопка бэкапа** (`POST /backup`, `internal/web/handlers_backup.go`): аутентифицированная отдача `application/gzip` вложением с `Cache-Control: no-store` (архив секретен). `web.Config` расширен `DataDir`/`DBPath`/`Version`. -- **Экспорт/импорт домена** (ТЗ 7.5.Б): `domain.Service.Export/Import` (`internal/domain/transfer.go`) + тип `DomainExport` (JSON: `format`/`version`/`domain`/`dkim_selector`/`dkim_private_key` PKCS#1 PEM/`applications[]` с `login`/`address_mode`/`addresses`/`password`). - - **Секреты SASL:** `SASLDB.Secret(login)` (`internal/app/sasl.go`) читает `sasldb2` через `db_dump` (Berkeley DB, фикс-argv, без shell — 7.6.3), парсит hex-пары, достаёт `userPassword` по ключу `login\0realm\0userPassword` (плейнтекст). `db-util` в образе. `ErrSecretNotFound` при отсутствии. - - **Ключи DKIM:** `OpenDKIM.ExportKey`/`ImportKey` — экспорт ре-маршалит ключ (ловит битые), импорт валидирует PKCS#1 и пишет атомарно (перезапись — импорт (пере)создаёт домен именно этим ключом, чтобы **DNS-запись не менялась**). - - **Импорт-оркестрация** (`Import`): `assertConfigSafe(domain,selector)` → `AddDomain` (UNIQUE — арбитр дубля, `ErrDomainExists`) → `ImportKey` → resync таблиц → на каждое приложение `apps.ImportApplication` (валидирует login/адреса/пароль, вставляет строку, `sasl.Set` **под локальным realm** = ре-кей) → один `apps.Resync` (карта Postfix). Любой сбой → `importRollback` (= обычный `Delete`: чистит SASL, каскад строк, обе карты, удаляет ключ). `Applications`-интерфейс расширен `Secret`/`ImportApplication`. - - **Веб** (`POST /domains/{id}/export` — секретная отдача JSON вложением `no-store`; `POST /domains/import` — multipart-загрузка ≤1 MiB, `DisallowUnknownFields`, нормализация+валидация имени домена как в add-форме, дружелюбные ошибки дубля/валидации баннером на дашборде, редирект на страницу нового домена с флешем «DNS менять не надо»). Карточка «Backup & migration» (скачать бэкап + импорт домена) на дашборде; карточка «Export domain» на странице домена; обе с предупреждением «файл — секрет». - - **Валидация импортного пароля** (`validateImportedPassword`): непустой, ≤1024, без управляющих символов (перевод строки обрезал бы passphrase на stdin `saslpasswd2`). -- **Проверено на сервере** (selfpost.example.com, образ `selfpost:p9`): `gofmt`/`vet`/`test` зелёные (юниты: backup create включает состояние/исключает transient+tls+wal, снимок — валидный SQLite; CheckRestore нет-манифеста/совпадение-потребляет/несовпадение-отказ+сохраняет/чужой-формат; парс `db_dump` секрета incl. realm-mismatch/not-found; import roundtrip + дубль-домена + rollback при сбое приложения; `ImportApplication` пишет строку+SASL, не трогает карту, реджект битого пароля/чужого адреса). **Контейнерный e2e:** (1) экспорт домена — JSON с PEM+паролем, пароль из `db_dump` **точно совпал** с показанным при создании; (2) импорт на **другой инстанс** (hostname `mail.dst.test` ≠ `mail.src.test`) — домен/приложение/ключ восстановлены, DKIM-ключ побитово тот же, **ре-кейнутый креденшл аутентифицировался (235) под новым realm** и письмо принято (250); (3) CLI-бэкап через `docker exec` (архив без `tls/`) и кнопка-бэкап (заголовки `no-store`/attachment); (4) restore на чистом контейнере той же версии с **тем же hostname** — стартует без setup, манифест потреблён, домен/приложение/DKIM/админ восстановлены, SMTP-auth работает; (5) **несовпадение версии** (manifest `1.3.0` vs бинарник `dev`) — панель отказывается стартовать с точным сообщением, недоступна, манифест сохранён, контейнер в итоге `Exited(0)` через crashexit. Все критерии «Готово когда» Фазы 9 выполнены. - -### Сделано в Фазе 8 -- **Уровень 2 rate-limit в journal-milter** (ТЗ 7.4): milter, бывший чистым монитором, теперь **отклоняет** письмо `4xx` (`milter.RespTempFail` = 451) при превышении дифференцированного лимита. Проверка на стадии **MAIL FROM** — самой ранней, где известны и домен (из `From`), и приложение (SASL-логин), — до предложения получателей. - - **Когда лимит применяется:** только если у домена/приложения заданы непустой список IP **и** потолок сообщений **и** окно, **и** client IP входит в этот список (`RateLimit.Active()` + `AllowsIP`). Пустая IP-привязка → уровень 2 не применяется (ТЗ 7.4: «оставить пустой → не применяется»); IP вне списка → остаётся только уровень 1 (anvil). Ключ — client IP из `Connect()` (ТЗ 7.4). - - **Счёт — сообщения, не получатели:** `COUNT(DISTINCT queue_id)` в скользящем окне (письмо на много получателей = одно письмо, как у уровня 1). Переиспользует `send_log` (ТЗ 7.4), исключает строки `rejected`. - - **Fail-open на собственных ошибках milter'а:** любая ошибка БД при lookup/count логируется и трактуется как «не превышено» — сбой лимитера никогда не блокирует почту; уровень 1 (anvil) не зависит от milter и остаётся backstop'ом (ТЗ 7.4). Отклоняет **только** чистый `count >= limit`. - - **Отклонённые письма** пишутся в `send_log` со статусом `rejected` (`InsertRejected`) для видимости в UI (ТЗ 7.4, опционально) — без queue-id/получателя (отклонено до постановки в очередь). -- **Store** (`internal/store/ratelimits.go`, миграция не нужна — таблица `rate_limits` заведена ещё в Фазе 2): `RateLimit`-тип (+`Active`/`AllowsIP` через `net.ParseIP`+`.Equal`), `GetRateLimit`/`SetRateLimit`(upsert по `UNIQUE(scope,ref_id)`)/`DeleteRateLimit` по id (панель), `RateLimit(scope,ref)` по имени домена/логину (milter, через JOIN), `CountMessages(scope,ref,since)` (distinct queue_id, исключая `rejected`), `DeleteRateLimitsForDomain` (домен + его приложения одним запросом). `StatusRejected`/`InsertRejected` в `sendlog.go`. IP хранятся как canonical CSV. Значения только как SQLite-параметры (не в конфиг-файлы) — инъекций нет. -- **Каскад:** `rate_limits.ref_id` — простое число без FK, поэтому очистка вручную: `domain.Service.Delete` зовёт `DeleteRateLimitsForDomain` **до** каскада приложений; `app.Service.Delete` зовёт `DeleteRateLimit`. (AUTOINCREMENT не переиспользует id, так что осиротевшие строки инертны, но чистим для порядка.) -- **Веб/UI** (`internal/web/handlers_ratelimit.go`, `handlers_apps.go`, шаблон `domain_detail.html`): серверная валидация (ТЗ 7.6.2) — каждый IP через `net.ParseIP`, потолок/окно — положительные int, окно по умолчанию 3600 (`RATE_LIMIT_WINDOW_SECONDS`); пустой список IP или явный `clear=1` → удаление лимита. Карточка «Sending rate limit (domain)» на странице домена + `
Rate limit` на каждом приложении (prefill из сохранённого состояния, кнопка «Remove limit», статус active/inactive). Ошибки валидации — баннером `RateLimitErr`. Роуты `POST /domains/{id}/ratelimit`, `POST /applications/{aid}/ratelimit`. Milter читает строку живьём — **reload не нужен**. Сервис-обёртки `RateLimit`/`SaveRateLimit`/`ClearRateLimit` на domain и app сервисах. -- **Проверено на сервере** (selfpost.example.com, контейнер `p8`): `gofmt`/`vet`/`test` зелёные (юниты: store set/get/upsert/delete, by-name/login, distinct+windowed count с исключением rejected, delete-for-domain, Active/AllowsIP incl. IPv6-форма; milter: reject при домен/app превышении, allow под лимитом, unregistered-IP игнор, инертный без потолка, fail-open на lookup/count-ошибке, no-IP-сессия). Контейнерный e2e (аутентифицированный SMTPS 465 изнутри контейнера, client=127.0.0.1): лимит `max=2` → 3-е письмо `SENDER-REFUSED 451`, ровно 2 в очереди, 1 строка `rejected`; **то же на уровне домена**; **unregistered IP** (198.51.100.1) → уровень 2 не применяется (оба письма прошли при `max=1`); **fail-open** — панель остановлена (`supervisorctl stop panel`) при `max=1` → почта принята; UI обеих форм рендерится. Уровень 1 (anvil) не тронут. - -### Сделано в Фазе 7 -- **Три экрана мониторинга** (`internal/web/handlers_monitor.go` + шаблоны `sendlog.html`/`queue.html`/`logtail.html` + фрагменты `sendlog_rows.html`/`queue_body.html`/`logtail_body.html`): - - **Журнал отправки** (`/sendlog`): таблица время/домен/приложение/From/To/Subject/статус, серверные фильтры по домену и логину приложения (`WHERE` через `store.SendLogFilter`, параметризовано), пагинация (50/страница, `LIMIT/OFFSET`, счётчик страниц через `CountSendLog`), HTMX-polling каждые 5с (`hx-trigger="every 5s"` на самообновляющемся `
`, `hx-swap="outerHTML"` — ответ фрагмента несёт те же hx-атрибуты, поэтому поллинг не обрывается). Вывод экранируется автоматически `html/template` (subject с `