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

21 KiB
Raw Blame History

План реализации: SelfPost

Статус: черновик для согласования (см. ТЗ, раздел 12, п. 7 — план утверждается до начала кодирования). Основа: 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/.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 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере.