diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..2d33214 --- /dev/null +++ b/docs/implementation-plan.md @@ -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.mixfed.ru` (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 подтверждены, сборка/вет/тесты/образ зелёные, контейнер стартует чисто. + +--- + +## Ключевые риски и как их снимаем + +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 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере. diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 0000000..b886f18 --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,65 @@ +# Прогресс реализации SelfPost + +Живой трекер фаз. **Переживает `/clear`** — читается первым при возобновлении работы. +План фаз: [implementation-plan.md](implementation-plan.md). ТЗ: [specification.md](specification.md). + +## Как возобновить после сброса контекста + +1. Прочитать этот файл (текущая фаза, статус, что сделано, что дальше). +2. Прочитать соответствующую фазу в `implementation-plan.md`. +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**. + +## Коммиты + +Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude Opus 4.8 `. + +## Протокол закрытия фазы + +Перед `/clear` в конце каждой фазы Claude: +1. Обновляет этот файл: статус фазы → ✅, заполняет «Сделано» и «Следующий шаг». +2. Проверяет критерии «Готово, когда…» из плана. +3. Пишет одну строку в журнал ниже. +4. Делает финальный коммит фазы. +5. Явно говорит: «Фаза N закрыта — можно `/clear`, следующая фаза N+1 на модели X». + +--- + +## Текущее состояние + +- **Текущая фаза:** 0 (ещё не начата) +- **Модель:** Opus +- **Статус:** план и решения утверждены; инфраструктура workflow настроена +- **Следующий шаг:** начать Фазу 0 — `go mod init codeberg.org/mix/selfpost`, структура каталогов, `LICENSE` (AGPL-3.0), скелет README, сборка с ldflags-версией; затем спайк go-milter на `selfpost.mixfed.ru`. + +## Утверждённые решения + +- Module path: `codeberg.org/mix/selfpost` +- SQLite: `modernc.org/sqlite` (чистый Go, без cgo) +- milter: `github.com/emersion/go-milter` (проверить совместимость в Фазе 0) +- Структура: стандартная Go-раскладка (`cmd/`, `internal/`) +- Front-end: `html/template` + вендоренный HTMX +- Коммиты — только по явной команде пользователя (ТЗ 12.1) + +## Журнал фаз + +_(пока пусто — заполняется по мере закрытия фаз)_