Add implementation plan and phase progress tracker

12-phase plan derived from the spec, plus a durable progress tracker
(model-per-phase, resume-after-reset protocol, commit conventions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:44:24 +03:00
parent 75d0329569
commit 9c31649941
2 changed files with 282 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
# План реализации: SelfPost
**Статус:** черновик для согласования (см. ТЗ, раздел 12, п. 7 — план утверждается до начала кодирования).
**Основа:** [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.56).
- Базовый 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.59).
**Готово, когда:** приложения создаются/редактируются/удаляются, карта привязки корректно пересобирается, пароль показывается один раз.
---
## Фаза 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 подтверждены, сборка/вет/тесты/образ зелёные, контейнер стартует чисто.
---
## Ключевые риски и как их снимаем
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 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере.