Files
selfpost/docs/implementation-plan.md
T
mix 9c31649941 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>
2026-07-11 14:44:24 +03:00

218 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План реализации: 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 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере.