bf477f4e6a
Add/list/delete of sending domains with per-domain DKIM keys and the OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10). internal/domain: - Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the published DNS TXT record is derived from the key on disk (single source of truth) rather than persisted. No os/exec for key generation. - KeyTable/SigningTable fully regenerated from the registry on every add/delete (idempotent), written atomically; SigningTable via refile: with *@domain, KeyTable with absolute key paths. Table writer refuses any unsafe character as a backstop (spec 7.6.4). - Reload without root: the unprivileged panel signals OpenDKIM through supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no shell, no user input — spec 7.6.3). An existing key is reused, never overwritten, so re-adding a domain keeps its published DNS valid. - Service orchestrates registry -> key -> table rebuild -> reload, with rollback of the row if a downstream step fails; delete cascades apps via the DB FK and removes the key + table entries. Infra: - Shared `selfpost` group bridges panel (writes keys) and opendkim (reads them); /data/opendkim is setgid so panel-created files inherit the group, keys are 0640, RequireSafeKeys is disabled by design. - opendkim.conf moves from verify-only (Mode v) to signing (Mode s). - entrypoint.sh normalises the DKIM tree on every start (ownership, setgid, perms, empty tables before opendkim starts) — self-healing after a restore. - supervisord control socket opened to the `selfpost` group so the panel can request the reload. web/store: - Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2 labels), lower-case normalisation (spec 7.6.2). - Domain queries with application counts; delete relies on ON DELETE CASCADE. Dashboard lists domains + add form; domain page shows the DKIM record; a dedicated confirm page warns about the app cascade before deletion (spec 7.2.4); manual reload button (spec 7.2.12, OpenDKIM side; Postfix reload lands in Phase 5). - Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard patterns. Tests: validateDomain, DKIM keygen/record roundtrip, table rendering + injection-safety, key reuse, store cascade. Verified on the dev server: gofmt/vet/test green, image builds, container e2e (add/delete a domain, DKIM record shown, OpenDKIM reads panel keys and reloads, keys and tables persist across a restart). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
20 KiB
Markdown
104 lines
20 KiB
Markdown
# Прогресс реализации 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 <noreply@anthropic.com>`.
|
||
|
||
## Протокол закрытия фазы
|
||
|
||
Перед `/clear` в конце каждой фазы Claude:
|
||
1. Обновляет этот файл: статус фазы → ✅, заполняет «Сделано» и «Следующий шаг».
|
||
2. Проверяет критерии «Готово, когда…» из плана.
|
||
3. Пишет одну строку в журнал ниже.
|
||
4. Делает финальный коммит фазы.
|
||
5. Явно говорит: «Фаза N закрыта — можно `/clear`, следующая фаза N+1 на модели X».
|
||
|
||
---
|
||
|
||
## Текущее состояние
|
||
|
||
- **Текущая фаза:** 3 ✅ закрыта → следующая **Фаза 4** (приложения + SASL + привязка к домену)
|
||
- **Модель для Фазы 4:** Opus (риск open relay / привязки отправителя, `sasldb2`, `smtpd_sender_login_maps`)
|
||
- **Статус:** домены + per-domain DKIM реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается, e2e в контейнере: add/delete домена, генерация ключа, показ TXT, reload OpenDKIM, персистентность ключей через рестарт)
|
||
- **Следующий шаг (Фаза 4):** создание приложения в карточке домена — SASL-пара логин/пароль в `sasldb2` (эквивалент `saslpasswd2`), сильный пароль, показ **один раз** (ТЗ 7.6.1); режим адресов «wildcard `@domain`» либо «список» с серверной валидацией принадлежности адреса домену (ТЗ 7.6.2); `smtpd_sender_login_maps` (many-to-one) соответственно режиму + `postfix reload`; список/редактирование режима/удаление приложения/перевыпуск пароля (ТЗ 7.2.5–9). **Важно для Фазы 4/5:** у панели уже есть безопасный privileged-reload через supervisord (см. журнал Фазы 3) — тот же паттерн (`supervisorctl signal`) переиспользовать для `postfix reload`/HUP; `sasldb2` должен переживать рестарт (bind-mount `/data`, ТЗ 9) и быть доступен Postfix/SASL — решить владельца/права по аналогии с DKIM-деревом.
|
||
|
||
### Сделано в Фазе 3
|
||
- **Per-domain DKIM в чистом Go** (`internal/domain/dkim.go`): RSA-2048 через `crypto/rsa`, приватный ключ PKCS#1 PEM пишется атомарно (temp+rename, `writeFileAtomic`) с mode 0640; DNS TXT-запись (`v=DKIM1; h=sha256; k=rsa; p=<base64 PKIX DER>`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `opendkim-genkey` **не** используется (никакого exec для keygen).
|
||
- **OpenDKIM KeyTable/SigningTable** (`internal/domain/opendkim.go`): полная регенерация обеих таблиц из реестра при каждом add/delete (идемпотентно), атомарная запись; `KeyTable` — абсолютный путь к ключу (OpenDKIM резолвит относительные от CWD — проверено), `SigningTable` через `refile:` с шаблоном `*@domain`. `assertConfigSafe` (backstop 7.6.4) отклоняет пробелы/переводы строк/`:`/`/` в имени/селекторе перед записью.
|
||
- **Reload OpenDKIM без root** (ключевое инфра-решение): панель (uid 999, `panel`) **не может** сигналить процесс opendkim (uid 100) напрямую. Reload = `supervisorctl signal USR1 opendkim` (exec, фикс-аргументы, без shell/ввода — 7.6.3); контрол-сокет supervisord открыт группе (`chown=root:selfpost chmod=0770`), `panel` в группе `selfpost`. `ExecReload` opendkim = `kill -USR1` (SIGUSR1 перечитывает таблицы) — подтверждено.
|
||
- **Межпользовательский доступ к ключам:** общая группа `selfpost` (в неё добавлены `panel` и `opendkim`); дерево `/data/opendkim` с setgid (mode 2750) → файлы, созданные панелью, наследуют группу `selfpost`; ключи 0640 → opendkim читает по группе. `RequireSafeKeys no` (ключи group-readable — безопасно на приватном single-tenant bind-mount). `entrypoint.sh` нормализует дерево на каждом старте (group/setgid/пермишены + пустые KeyTable/SigningTable до старта opendkim), самолечение после restore. `opendkim.conf` переведён Mode `v`→`s`.
|
||
- **Сервис/веб** (`internal/domain/service.go`, `internal/store/domains.go`, `internal/web/handlers_domains.go`): Add (реестр→ключ→rebuild→reload, откат строки при сбое; существующий ключ переиспользуется, не перегенерируется — иначе сломается опубликованный DNS), Delete (каскад приложений через FK `ON DELETE CASCADE` + удаление ключа + rebuild+reload), список на дашборде со счётчиком приложений, карточка домена с TXT-записью (7.2.10), отдельная страница подтверждения удаления с предупреждением о каскаде (7.2.4), ручной Reload (7.2.12, пока только OpenDKIM — Postfix в Фазе 5). Валидация имени домена — строгий whitelist `[a-z0-9.-]`, DNS-форма, ≥2 меток (7.6.2), нормализация в lower-case. Роутинг переведён на authenticated под-mux с method+wildcard-паттернами Go 1.22.
|
||
- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`go vet`/`go test` (юниты: validateDomain, DKIM keygen/record roundtrip, renderTables + injection-safety, EnsureKey reuse, store cascade) зелёные; `docker build` ок; контейнерный e2e (curl+сессия): три процесса живы, add `Example.COM`→нормализация→ключ+таблицы+TXT, opendkim читает ключ панели и остаётся RUNNING после reload, **рестарт → ключ и таблицы персистентны (хэш совпал)**, delete → каскад, удаление ключа, пустые таблицы, opendkim reload; лог панели без ошибок.
|
||
|
||
### Сделано в Фазе 2
|
||
- **SQLite-персистентность** (`internal/store`): драйвер `modernc.org/sqlite` (чистый Go, без cgo — статик-бинарник сохранён; первые сторонние зависимости → появились `go.mod` require + `go.sum`), WAL + `foreign_keys(ON)` + `busy_timeout` через DSN `_pragma`, `MaxOpenConns(1)`. Встроенные (`embed`) нумерованные миграции с версионированием через `PRAGMA user_version`; миграция `0001_init.sql` заводит всю схему ТЗ 9: `admin` (одна строка, `CHECK id=1`), `settings`, `domains`, `applications`, `application_addresses`, `send_log` (+индексы), `rate_limits`. Запросы `AdminExists/CreateAdmin/GetAdmin`.
|
||
- **Setup secret-link** (`internal/web/setup.go`, ТЗ 7.6.1): токен 128 бит из `crypto/rand` (base64url), ссылка `https://<SELFPOST_HOSTNAME>/setup/<token>` печатается в лог **и** пишется в `/data/setup-token` (0600); TTL 10 мин с перегенерацией при истечении/рестарте (пока нет админа); сравнение `subtle.ConstantTimeCompare`; **неудачи НЕ инвалидируют токен**; «настройка завершена» = наличие строки `admin` (источник истины), поэтому после успеха токен сгорает навсегда и весь `/setup/*` → 404. Форма создания админа — одноразовая; гонка двух POST безопасна (`CHECK id=1` + проверка существования).
|
||
- **Пароль админа** — только `bcrypt` (`golang.org/x/crypto/bcrypt`, DefaultCost); серверная валидация (username whitelist 7.6.2, пароль ≥12).
|
||
- **Логин + сессии** (`internal/web/handlers_auth.go`, `session.go`): вход сверяет username + bcrypt (bcrypt считается всегда — timing-инвариантно), сессии в памяти (не в списке ТЗ 9 на персист — рестарт просто разлогинивает), крипто-токен 256 бит; cookie `HttpOnly`/`Secure`/`SameSite=Lax` (`Secure` по умолчанию, отключается `PANEL_COOKIE_SECURE=false` только для dev-HTTP); rate-limit логина (`ratelimit.go`, 10/15мин по IP) и отдельный на `/setup` (10/мин); auth-middleware защищает панель.
|
||
- **Front-end**: базовый layout `html/template` (автоэкранирование, 7.6.7) + страницы setup/login/dashboard (`embed`); вендоренный `htmx.min.js` 2.0.4 (`internal/web/static`, отдаётся с `/static/`).
|
||
- **Entrypoint для bind-mount** (`build/entrypoint.sh`): `/data` — host bind mount → приходит от root, а панель работает под непривилегированным `panel` (uid 999). Точка входа под root чинит владельца `/data` перед `exec supervisord` (иначе SQLite `unable to open database file`). Найдено и исправлено при контейнерной проверке.
|
||
- **Проверено на сервере** (selfpost.mixfed.ru): `go vet`/`go build`/`go test`/`gofmt -l` чисто; функциональный e2e (curl): setup-ссылка печатается+в файл, `GET /setup/<token>`→200, неверный/просроченный→404, `POST /setup` создаёт админа→303, повторный `/setup`→404, файл токена удалён; плохой логин→401, хороший→303 с cookie `HttpOnly; Secure; SameSite=Lax`, `/`→200, logout→303, после logout `/`→303; рестарт с существующим админом не печатает setup. Docker-образ собирается; контейнер с `-v ./data:/data`: три процесса, БД+токен создаются под `panel`, токен 0600.
|
||
|
||
### Сделано в Фазе 1
|
||
- **Образ** `build/Dockerfile` (bookworm-slim): многостадийная статическая сборка Go (`CGO_ENABLED=0`, `go vet` в сборке); рантайм — postfix, opendkim(+tools), cyrus-sasl (`sasl2-bin`, `libsasl2-modules`), supervisor, logrotate, ca-certificates; `maillog_file=/var/log/mail.log`; непривилегированный пользователь `panel` (ТЗ 7.6.8); `.dockerignore` (dev/ и docs/ не попадают в контекст).
|
||
- **supervisord** (`build/supervisord.conf`, `nodaemon`, PID 1): порядок `priority=` opendkim(100)→panel(200)→postfix(300); event-listener `crashexit.py` на `PROCESS_STATE_FATAL` шлёт SIGTERM супервизору → контейнер завершается при неперезапускаемом падении (ТЗ 4).
|
||
- **Обёртка Postfix** (`build/postfix-wrapper.sh`): опрос `test -S` обоих milter-сокетов (OpenDKIM `/run/opendkim/opendkim.sock` + journal `/run/selfpost/journal.sock`), таймаут 30с, при неготовности — выход ≠0 без запуска Postfix; иначе `exec postfix start-fg` (решает только холодный старт, ТЗ 4).
|
||
- **Панель** (`cmd/panel`, три роли под общим ctx + graceful shutdown по SIGTERM): HTTP `:8080` заглушка + `/healthz`; **stub journal-milter** — открывает unix-сокет, чтобы обёртка проходила (реальный milter — Фаза 6); **stub log-tailer** (реальный хвост — Фаза 6). `opendkim.conf` временно Mode `v` (без ключей; Mode `s`+KeyTable — Фаза 3).
|
||
- **Проверено на сервере** (selfpost.mixfed.ru): `docker build` ок; `docker run` → три живых процесса (opendkim, panel@non-root, postfix master); панель `/`→200 и `/healthz`→ok; обёртка дождалась сокетов; форсированный FATAL панели → crashexit → контейнер завершился чисто; SIGTERM → «panel stopped cleanly». Образ 334MB, тег `selfpost:dev` оставлен на сервере.
|
||
|
||
### Сделано в Фазе 0
|
||
- Go-модуль `codeberg.org/mix/selfpost`, каркас `cmd/panel` + `cmd/selfpost-backup`, общий `internal/buildinfo` (версия через ldflags).
|
||
- `Makefile` (статическая сборка `CGO_ENABLED=0`), `LICENSE` (полный AGPL-3.0), скелет `README.md`, `.gitattributes` (LF).
|
||
- Проверено на сервере: `go vet` чист, `make build` даёт статические бинарники, версия впечатывается.
|
||
- **Спайк go-milter** (де-риск ТЗ 7.3): v0.4.1 (BSD-2) ↔ Postfix 3.7.11 bookworm, протокол v6. Подтверждено чтение From/To(per-rcpt)/Subject/queue-id, client IP из Connect(), **fail-open** при падении milter'а. Детали: `dev/spike-milter-notes.md`.
|
||
- **Провижён dev-сервер:** Go 1.26.5, Docker 29.6.1, git, rsync, make. Host-postfix (спайковый) остановлен+отключён.
|
||
|
||
## Рабочая петля (dev loop) — ВАЖНО
|
||
|
||
Локально (Windows, `D:\Local\Git\selfpost`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.mixfed.ru` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → `rsync` дерева на сервер → `go build`/`go vet`/`docker build`/тесты там. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@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)
|
||
|
||
## Журнал фаз
|
||
|
||
- **Фаза 0** (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты `4e589e1` (каркас), `87388b4` (план).
|
||
- **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`.
|
||
- **Фаза 2** (2026-07-11, Opus) — SQLite (`modernc.org/sqlite`, миграции, схема ТЗ 9), setup secret-link (128-бит токен, TTL 10м, const-time, одноразово), bcrypt-админ, логин/сессии/cookie-флаги, rate-limit setup+логина, html/template + вендоренный HTMX, auth-middleware; entrypoint чинит владельца bind-mount `/data`. Проверено на сервере (e2e curl + docker run).
|
||
- **Фаза 3** (2026-07-11, Opus) — домены + per-domain DKIM: keygen в чистом Go (RSA-2048, PKCS#1 PEM, TXT из ключа), OpenDKIM KeyTable/SigningTable (Mode `s`, `refile:`) с полной регенерацией, reload через `supervisorctl signal USR1 opendkim` (сокет открыт группе `selfpost`, панель без root — 7.6.3/7.6.8), межпользовательский доступ к ключам через общую группу `selfpost`+setgid+`RequireSafeKeys no`, add/delete/список/TXT/подтверждение каскада/ручной reload, строгая валидация имени домена (7.6.2), injection-safe запись таблиц (7.6.4). Юнит-тесты + контейнерный e2e (add/delete, персистентность ключей через рестарт, reload) зелёные. Проверено на сервере.
|