Files
selfpost/docs/progress.md
T
mix 88f9d33e8d deploy: add CAP_FOWNER/CAP_FSETID so entrypoint permission-fix works
Bringing up the production Apache stack for real surfaced a latent bug
in the Phase 10 hardening: cap_drop: ALL with only NET_BIND_SERVICE/
CHOWN/SETUID/SETGID/DAC_OVERRIDE left the root startup phase unable to
chmod the /data dirs it had just chowned to the panel user (needs
CAP_FOWNER) or set their setgid bit (needs CAP_FSETID). The container
crash-looped on "chmod: Operation not permitted". Phase 10 never caught
this because its compose up hit a port conflict before full boot.

Add FOWNER and FSETID to cap_add and document what each capability is
for. Verified: container now starts clean under the hardened compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:18:44 +03:00

208 lines
81 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
Живой трекер фаз. **Переживает `/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.34) |
| 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».
---
## Текущее состояние
- **Текущая фаза:** 11 ✅ **закрыта****базовый линейный план 0→11 (v1.0) полностью выполнен**. Дальше — только опциональные фазы O1+ линии 2.x.x (входящий релей), которые **не входят в v1.0** и требуют явного согласования (ТЗ 12.6, раздел 3). Модель для будущих фаз — по таблице.
- **Итог Фазы 11 (аудит 7.6):** сквозная проверка всех 8 пунктов раздела 7.6 против кода — **соответствие полное, изменений кода не потребовалось**. Единственная остаточная заметка (не дефект, спец-совместимо): rate-limit логина/`/setup` кеится по `RemoteAddr` (`clientIP` намеренно НЕ парсит `X-Forwarded-For`, чтобы его нельзя было подделать — [web.go:147](../internal/web/web.go)). За обратным прокси (дефолтный Apache-деплой) это адрес прокси, т.е. лимитеры фактически глобальны — осознанный и задокументированный компромисс: для setup-токена реальная защита — 128-бит энтропии (ТЗ 7.6.1 прямо называет rate-limit defense-in-depth, не основной мерой), для логина простой per-IP счётчик достаточен по ТЗ 7.6.5. Парсинг XFF был бы хуже (позволил бы обойти лимит подделкой заголовка). Если оператору нужен настоящий per-client лимит за прокси — это будущее улучшение через trusted-proxy XFF, а не правка v1.0.
- **Боевой деплой поднят (2026-07-15)** на `selfpost.mixfed.ru` (81.30.105.2) по дефолтному сценарию ТЗ 10 (**Apache на хосте**): образ `ghcr.io/mixeme/selfpost:1.0.0` (собран локально, `VERSION=1.0.0`), compose в `/root/selfpost-deploy/` (`./data`, `./certs`, `SUBMISSION_ENABLE=true`), реальный сертификат Let's Encrypt (`certbot --apache`, автопродление + `--deploy-hook`, копирующий PEM в `./certs` с `privkey 0640 gid=104` = контейнерный `postfix`), Apache vhost — reverse-proxy панели `443→127.0.0.1:8080` + `80→443` редирект + security-заголовки (HSTS/nosniff/X-Frame-Options — из бэклога A2). Проверено: панель `https://selfpost.mixfed.ru` (healthz 200, http→301), **SMTPS 465 и submission 587 отдают валидный LE-серт** (`Verify return code: 0`), контейнер `restarts=0`, все процессы RUNNING.
- **Найден и исправлен реальный баг деплой-артефакта Фазы 10 (боевой прогон вскрыл):** `deploy/docker-compose.yml` с `cap_drop: ALL` не давал entrypoint'у нужных `CAP_FOWNER`/`CAP_FSETID` — root не мог `chmod`/setgid уже перечоуненные на `panel` каталоги `/data``chmod: Operation not permitted` и **crash-loop** контейнера. В Фазе 10 это не поймали, т.к. тогдашний `docker compose up` упёрся в конфликт порта 465 (контейнер `p6`) и не догрузился. Фикс: добавлены `FOWNER`+`FSETID` в `cap_add` (+ расширен комментарий, какая capability зачем). Проверено — контейнер стартует чисто.
- **Прежняя фаза:** 10 ✅ закрыта.
- **Ключевая находка Фазы 9 (SASL-секреты обратимы, как и предвидело ТЗ 7.5.Б):** `sasldb2` (Berkeley DB, db5.3) хранит пароль приложения как **плейнтекст** в свойстве `userPassword` — подтверждено на сервере. Значит экспорт домена читает его через `db_dump` и на импорте **перезаписывает под локальный realm** через `saslpasswd2` (плейнтекст realm-независим) → креды работают на другом хосте с ДРУГИМ hostname/realm без перевыпуска. Полный бэкап копирует `sasldb2` **побитово** (keyed по исходному realm), поэтому restore обязан идти на **тот же hostname** (миграция всей машины). `db-util` (даёт `db_dump`) добавлен явной зависимостью в Dockerfile. Файл `/data/setup-token` содержит **полный URL**, а не голый токен (для e2e: `TOKEN=${FULL##*/}`).
- **Ключевая находка Фазы 6 (исправлена):** go-milter хранит имена макросов **как их шлёт Postfix** — многосимвольные имена приходят в фигурных скобках (`{auth_authen}`, `{client_addr}`), односимвольные — голыми (`i`). Спайк Фазы 0 без SASL этого не увидел (`auth_authen` был пуст «и так»). Первый прогон в контейнере дал пустой `app_login`; фикс — brace-толерантный `macro(m,name)` (пробует голый ключ, затем `{name}`). Зафиксировано в памяти [[milter-implementation-facts]].
- **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`).
- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@mixdelta.ru → selfpost@mixeme.ru` доставлено и принято `mc.mixfed.ru`, заголовок `Authentication-Results: dkim=pass (d=mixdelta.ru s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID`у mixdelta.ru только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mc.mixfed.ru` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `mixdelta.ru` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / mixdelta.ru id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `mixdelta.ru` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
### Сделано в Фазе 11
- **Финальный проход по безопасности (ТЗ 7.6) — построчный аудит кода по всем 8 пунктам, все подтверждены:**
1. **Setup secret-link** ([setup.go](../internal/web/setup.go), [token.go](../internal/web/token.go)): токен 128 бит `crypto/rand` (`randomToken(16)`, base64url — 22 симв.), TTL 10 мин с ре-генерацией при истечении/рестарте, сравнение `subtle.ConstantTimeCompare`, **неудачи НЕ инвалидируют** токен, одноразовость через наличие строки admin (после успеха `/setup/*`→404), файл токена `0600`, пароль админа только bcrypt (`DefaultCost`).
2. **Серверная валидация** ([validate.go](../internal/web/validate.go), [app/validate.go](../internal/app/validate.go)): строгие whitelist для домена (DNS-форма, ≥2 меток, `[a-z0-9.-]`), логина (без `@`), localpart; **проверка принадлежности адреса домену до записи в конфиг** (`validateSenderAddress`); импорт-путь ([handlers_backup.go](../internal/web/handlers_backup.go)) — `MaxBytesReader` 1 MiB + `DisallowUnknownFields` + `normalizeDomain`/`validateDomain`, а `ImportApplication` валидирует login/адреса/пароль (`validateImportedPassword` — непустой, ≤1024, без управляющих).
3. **`os/exec` без shell** — все 5 сайтов фикс-argv, без `sh -c`: `saslpasswd2` (пароль по stdin, логин whitelisted argv), `db_dump`, `postqueue -p`, два `supervisorctl` (reload OpenDKIM/Postfix). Пользовательский ввод в аргументы команд не попадает вообще (кроме whitelisted логина).
4. **Санитизация записи в конфиги** ([opendkim.go](../internal/domain/opendkim.go) `assertConfigSafe`, [postfix.go](../internal/postfix/postfix.go) `assertMapSafe`) — hard-backstop против пробелов/переводов строк/разделителей перед записью KeyTable/SigningTable/sender-map, атомарная запись (temp+rename).
5. **Rate-limit логина** ([ratelimit.go](../internal/web/ratelimit.go), 10/15мин) + **отдельный на `/setup`** (10/мин).
6. **Сессии/cookie** ([session.go](../internal/web/session.go), [handlers_auth.go](../internal/web/handlers_auth.go)): токен 256 бит `crypto/rand`, cookie `HttpOnly`+`Secure`(дефолт true)+`SameSite=Lax` (Lax закрывает CSRF на POST-мутациях — все мутации POST, все GET read-only).
7. **Экранирование вывода** ([templates.go](../internal/web/templates.go)) — только `html/template`, ни одного `text/template`/`template.HTML`/`Safe*`-байпаса.
8. **Панель не под root** ([supervisord.conf](../build/supervisord.conf) `[program:panel] user=panel`; root только в entrypoint-бутстрапе и supervisord-PID1; межпользовательский доступ — через группу `selfpost`+setgid, не через привилегии).
- **Приёмка ТЗ 12.2–3 (на сервере selfpost.mixfed.ru, образ `selfpost:p11`):** `gofmt -l` пусто, `go vet ./...` чист, `go build ./...` ок, `go test ./...` — все пакеты зелёные; `docker build` ок; **чистый старт контейнера подтверждён вживую:** все долгоживущие процессы `RUNNING` (opendkim/panel/postfix/cert-reload/logrotate/crashexit; `postfix-reload` `STOPPED` — штатный on-demand one-shot), `restarts=0`; **панель под Uid/Gid 999 (`panel`, не root)** проверено по `/proc/<pid>/status`; setup-ссылка напечатана в лог с 128-бит токеном, `/data/setup-token` `0600 panel:panel`; live-проверки: битый setup-токен→**404** (маршрут скрыт), неаутентифицированный `/domains`→**303 `/login`** (auth-middleware), `/healthz`→**200**. Все критерии «Готово когда» Фазы 11 выполнены.
### Сделано в Фазе 10
- **Найден и закрыт пробел с Фазы 1:** `logrotate` был установлен пакетом в образ, но никогда не запускался (ни cron, ни supervisor-программы) — `mail.log` рос бы неограниченно. Добавлены `build/logrotate-mail.conf` (`/var/log/mail.log`, `daily`/`rotate 14`/`compress`/`delaycompress`/`copytruncate`) и `build/logrotate-loop.sh` (poll-цикл, дефолт 6ч; сам `logrotate` решает, пора ли ротировать, по `/var/lib/logrotate/status`) + supervisor-программа `[program:logrotate]` (по образцу `cert-reload`). **`copytruncate`, а не сигнал Postfix** — `maillog_file` пишет `postlogd`, который держит файл открытым весь свой жизненный цикл, и никакой демон ротацию не подхватывает; `copytruncate` избавляет от необходимости `postfix reload` на каждую ротацию ценой маленького окна потери нескольких строк лога при truncate — приемлемо для мониторингового лога. Проверено на сервере: `logrotate -f` реально ротирует (`mail.log` truncated, `mail.log.1` с прежним содержимым), процесс `logrotate` в `supervisorctl status` — RUNNING.
- **`deploy/docker-compose.yml`** (ТЗ 10, 10.5): единственный сервис `selfpost`, **Apache не контейнеризован** — предполагается, что он уже стоит на хосте (целевая аудитория — ТЗ 10.5), с фрагментом `deploy/apache/selfpost-vhost.conf` (host-vhost + certbot Apache-плагин, готовые PEM без шага извлечения). Образ — **фиксированный тег** `ghcr.io/mixeme/selfpost:X.Y.Z` (не `:latest`, ТЗ 10 п.10, обязательное условие для проверки версии при restore из Фазы 9). Порты 465/587 публикуются напрямую (почта мимо Apache), 8080 — только на `127.0.0.1` (Apache достаёт панель по localhost, наружу панель без TLS не видна). Hardening (ТЗ 10 п.6): `no-new-privileges`, `cap_drop: ALL` + точечный `cap_add` (`NET_BIND_SERVICE`/`CHOWN`/`SETUID`/`SETGID`/`DAC_OVERRIDE` — entrypoint по-прежнему стартует под root на доли секунды, чтобы починить владельца `/data` и права `selfpost`-группы, см. Фазы 3-4, поэтому `user: panel` и read-only rootfs целиком не подходят).
- **Альтернативные фрагменты reverse-proxy** (ТЗ 10.3, каждый — отдельный `docker compose -f docker-compose.yml -f <proxy>/docker-compose.<proxy>.yml`): `deploy/nginx/` (контейнеризованный nginx + certbot-сайдкар, PEM тем же bind-mount способом, что и Apache), `deploy/caddy/` (автоматический ACME без доп. контейнеров; путь хранения PEM у Caddy включает имя ACME CA как сегмент пути — **явно помечено как нестабильная внутренняя деталь**, требует проверки под конкретную версию, как и просило ТЗ), `deploy/traefik/` (сертификаты внутри `acme.json`, `deploy/traefik/extract-cert.sh` — скрипт извлечения PEM через `jq`). **Важная находка при проверке:** Docker Compose резолвит все относительные пути в объединяемых файлах относительно каталога **первого** `-f`-файла (т.е. `deploy/`), а не каталога самого фрагмента — путь во фрагментах поэтому не `./nginx.conf.example`, а `./nginx/nginx.conf.example` и т.п.; проверено `docker compose config` для всех трёх фрагментов на сервере (пути резолвились верно только после этого фикса). Списки `ports`/`volumes` в фрагментах используют YAML-тег `!override` (Docker Compose merge-тег) — без него списки **дополняются**, а не заменяются, и тогда порт 8080 из базового файла остался бы опубликован рядом с портами прокси; подтверждено на сервере (`docker compose config` показывает итоговый `selfpost` только с портами/томами фрагмента, не суммой).
- **CI-публикация образа** (`.github/workflows/release.yml`, ТЗ 10.1): триггер — push тега `vX.Y.Z` (обычные коммиты ничего не публикуют); версия выводится из тега один раз (`GITHUB_REF_NAME` без `v`) и идёт **и** в `-ldflags` бинарника, **и** в тег образа — структурно исключает рассинхрон, на который опирается проверка версии при restore (Фаза 9). Публикация в `ghcr.io/mixeme/selfpost` (обоснование ТЗ 10.1: `ghcr` бесплатен и без анонимного rate-limit, в отличие от Docker Hub). Мультиплатформенная сборка (`linux/amd64`+`linux/arm64`) через `buildx`/`qemu`.
- **README.md переписан** (ТЗ 10 п.7-11): чеклист «Requirements», Quick start со ссылкой на `deploy/`, раздел «Reverse proxy» (таблица всех 4 вариантов + обоснование дефолта Apache), «DNS setup» (явное разделение уровня сервера/уровня домена — PTR один раз vs SPF/DKIM/DMARC на каждый добавленный домен), «IP warmup», «Backup, restore, and moving a single domain» (полный бэкап vs экспорт/импорт домена — разница в сценарии переноса, оба файла — секреты), «Fixed image tag» (связь с проверкой версии restore), «Machine requirements» (1 vCPU, 512МБ-1ГБ, 8-10ГБ диска, рекомендация swap).
- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`vet`/`test` зелёные (Go-код фазы не менялся); `docker build` образа с новым logrotate-путём — ок (`selfpost:p10`); контейнер стартует, все процессы (включая новый `logrotate`) RUNNING; ручная принудительная ротация мониторингового лога отработала; `docker compose config` — валиден для базового файла и всех трёх альтернативных фрагментов (nginx/caddy/traefik), после чего смёрженный `selfpost`-сервис содержит ожидаемые порты/тома/env, а не сумму базового+фрагмента; сборка `docker compose up` базового файла реально создаёт контейнер и сеть (сама попытка упёрлась только в конфликт порта 465 с посторонним контейнером `p6`, оставшимся от более ранней фазы, — не дефект текущей конфигурации). CI-workflow не запускался (нет прав пушить теги в этой сессии) — синтаксис YAML проверен парсером.
### Сделано в Фазе 9
- **Полный бэкап** (`internal/backup/backup.go`, ТЗ 7.5.А): `Create(w, Params)` пишет `tar.gz` всего `/data`**консистентный снимок SQLite через `VACUUM INTO`** во временный файл (не побайтовое копирование живого WAL-файла), DKIM-ключи, `sasldb2`, карта Postfix, + `manifest.json` (`format`/`version`/`created_at`). Имена в архиве — относительно `/data`, так что распаковка в bind-mount восстанавливает состояние на месте. **Исключаются**: живой `selfpost.db`(+`-wal`/`-shm`/`-journal`, заменён снимком под тем же именем), `setup-token`, стейл-`manifest.json`, и каталог **`tls/`** — сертификаты это зона reverse-proxy (ТЗ 7.5.А); исключение держит гарантию даже если оператор положил серты в `/data/tls`. Каталоги-записи сохраняются (моды/пустые). Очередь Postfix не входит (ТЗ).
- **Гварда версии при restore** (`CheckRestore`, вызывается в `run()` **до** `store.Open` в `cmd/panel/main.go`): если в `/data` лежит `manifest.json` (значит бэкап распакован), его версия обязана совпасть с версией бинарника, иначе панель **отказывается стартовать** с сообщением, каким тегом образа восстанавливать (`selfpost:<ver>`); при совпадении манифест **потребляется** (удаляется) — гвардит только первый старт после restore и не блокирует обычный in-place апгрейд образа; отсутствие манифеста = обычный старт. Restore — не отдельная ветка кода: состояние (Postfix/OpenDKIM) регенерируется из восстановленного SQLite тем же путём, что при любом старте.
- **CLI `selfpost-backup`** (`cmd/selfpost-backup/main.go`, ТЗ 11.6): по умолчанию пишет `tar.gz` в **stdout** (`docker exec <c> selfpost-backup > backup.tar.gz`), флаг `-o` — в файл (0600). Читает `SELFPOST_DATA_DIR`/`SELFPOST_DB_PATH` из env, зовёт `backup.Create`. Эквивалент кнопки в панели.
- **Кнопка бэкапа** (`POST /backup`, `internal/web/handlers_backup.go`): аутентифицированная отдача `application/gzip` вложением с `Cache-Control: no-store` (архив секретен). `web.Config` расширен `DataDir`/`DBPath`/`Version`.
- **Экспорт/импорт домена** (ТЗ 7.5.Б): `domain.Service.Export/Import` (`internal/domain/transfer.go`) + тип `DomainExport` (JSON: `format`/`version`/`domain`/`dkim_selector`/`dkim_private_key` PKCS#1 PEM/`applications[]` с `login`/`address_mode`/`addresses`/`password`).
- **Секреты SASL:** `SASLDB.Secret(login)` (`internal/app/sasl.go`) читает `sasldb2` через `db_dump` (Berkeley DB, фикс-argv, без shell — 7.6.3), парсит hex-пары, достаёт `userPassword` по ключу `login\0realm\0userPassword` (плейнтекст). `db-util` в образе. `ErrSecretNotFound` при отсутствии.
- **Ключи DKIM:** `OpenDKIM.ExportKey`/`ImportKey` — экспорт ре-маршалит ключ (ловит битые), импорт валидирует PKCS#1 и пишет атомарно (перезапись — импорт (пере)создаёт домен именно этим ключом, чтобы **DNS-запись не менялась**).
- **Импорт-оркестрация** (`Import`): `assertConfigSafe(domain,selector)``AddDomain` (UNIQUE — арбитр дубля, `ErrDomainExists`) → `ImportKey` → resync таблиц → на каждое приложение `apps.ImportApplication` (валидирует login/адреса/пароль, вставляет строку, `sasl.Set` **под локальным realm** = ре-кей) → один `apps.Resync` (карта Postfix). Любой сбой → `importRollback` (= обычный `Delete`: чистит SASL, каскад строк, обе карты, удаляет ключ). `Applications`-интерфейс расширен `Secret`/`ImportApplication`.
- **Веб** (`POST /domains/{id}/export` — секретная отдача JSON вложением `no-store`; `POST /domains/import` — multipart-загрузка ≤1 MiB, `DisallowUnknownFields`, нормализация+валидация имени домена как в add-форме, дружелюбные ошибки дубля/валидации баннером на дашборде, редирект на страницу нового домена с флешем «DNS менять не надо»). Карточка «Backup & migration» (скачать бэкап + импорт домена) на дашборде; карточка «Export domain» на странице домена; обе с предупреждением «файл — секрет».
- **Валидация импортного пароля** (`validateImportedPassword`): непустой, ≤1024, без управляющих символов (перевод строки обрезал бы passphrase на stdin `saslpasswd2`).
- **Проверено на сервере** (selfpost.mixfed.ru, образ `selfpost:p9`): `gofmt`/`vet`/`test` зелёные (юниты: backup create включает состояние/исключает transient+tls+wal, снимок — валидный SQLite; CheckRestore нет-манифеста/совпадение-потребляет/несовпадение-отказ+сохраняет/чужой-формат; парс `db_dump` секрета incl. realm-mismatch/not-found; import roundtrip + дубль-домена + rollback при сбое приложения; `ImportApplication` пишет строку+SASL, не трогает карту, реджект битого пароля/чужого адреса). **Контейнерный e2e:** (1) экспорт домена — JSON с PEM+паролем, пароль из `db_dump` **точно совпал** с показанным при создании; (2) импорт на **другой инстанс** (hostname `mail.dst.test``mail.src.test`) — домен/приложение/ключ восстановлены, DKIM-ключ побитово тот же, **ре-кейнутый креденшл аутентифицировался (235) под новым realm** и письмо принято (250); (3) CLI-бэкап через `docker exec` (архив без `tls/`) и кнопка-бэкап (заголовки `no-store`/attachment); (4) restore на чистом контейнере той же версии с **тем же hostname** — стартует без setup, манифест потреблён, домен/приложение/DKIM/админ восстановлены, SMTP-auth работает; (5) **несовпадение версии** (manifest `1.3.0` vs бинарник `dev`) — панель отказывается стартовать с точным сообщением, недоступна, манифест сохранён, контейнер в итоге `Exited(0)` через crashexit. Все критерии «Готово когда» Фазы 9 выполнены.
### Сделано в Фазе 8
- **Уровень 2 rate-limit в journal-milter** (ТЗ 7.4): milter, бывший чистым монитором, теперь **отклоняет** письмо `4xx` (`milter.RespTempFail` = 451) при превышении дифференцированного лимита. Проверка на стадии **MAIL FROM** — самой ранней, где известны и домен (из `From`), и приложение (SASL-логин), — до предложения получателей.
- **Когда лимит применяется:** только если у домена/приложения заданы непустой список IP **и** потолок сообщений **и** окно, **и** client IP входит в этот список (`RateLimit.Active()` + `AllowsIP`). Пустая IP-привязка → уровень 2 не применяется (ТЗ 7.4: «оставить пустой → не применяется»); IP вне списка → остаётся только уровень 1 (anvil). Ключ — client IP из `Connect()` (ТЗ 7.4).
- **Счёт — сообщения, не получатели:** `COUNT(DISTINCT queue_id)` в скользящем окне (письмо на много получателей = одно письмо, как у уровня 1). Переиспользует `send_log` (ТЗ 7.4), исключает строки `rejected`.
- **Fail-open на собственных ошибках milter'а:** любая ошибка БД при lookup/count логируется и трактуется как «не превышено» — сбой лимитера никогда не блокирует почту; уровень 1 (anvil) не зависит от milter и остаётся backstop'ом (ТЗ 7.4). Отклоняет **только** чистый `count >= limit`.
- **Отклонённые письма** пишутся в `send_log` со статусом `rejected` (`InsertRejected`) для видимости в UI (ТЗ 7.4, опционально) — без queue-id/получателя (отклонено до постановки в очередь).
- **Store** (`internal/store/ratelimits.go`, миграция не нужна — таблица `rate_limits` заведена ещё в Фазе 2): `RateLimit`-тип (+`Active`/`AllowsIP` через `net.ParseIP`+`.Equal`), `GetRateLimit`/`SetRateLimit`(upsert по `UNIQUE(scope,ref_id)`)/`DeleteRateLimit` по id (панель), `RateLimit(scope,ref)` по имени домена/логину (milter, через JOIN), `CountMessages(scope,ref,since)` (distinct queue_id, исключая `rejected`), `DeleteRateLimitsForDomain` (домен + его приложения одним запросом). `StatusRejected`/`InsertRejected` в `sendlog.go`. IP хранятся как canonical CSV. Значения только как SQLite-параметры (не в конфиг-файлы) — инъекций нет.
- **Каскад:** `rate_limits.ref_id` — простое число без FK, поэтому очистка вручную: `domain.Service.Delete` зовёт `DeleteRateLimitsForDomain` **до** каскада приложений; `app.Service.Delete` зовёт `DeleteRateLimit`. (AUTOINCREMENT не переиспользует id, так что осиротевшие строки инертны, но чистим для порядка.)
- **Веб/UI** (`internal/web/handlers_ratelimit.go`, `handlers_apps.go`, шаблон `domain_detail.html`): серверная валидация (ТЗ 7.6.2) — каждый IP через `net.ParseIP`, потолок/окно — положительные int, окно по умолчанию 3600 (`RATE_LIMIT_WINDOW_SECONDS`); пустой список IP или явный `clear=1` → удаление лимита. Карточка «Sending rate limit (domain)» на странице домена + `<details>Rate limit` на каждом приложении (prefill из сохранённого состояния, кнопка «Remove limit», статус active/inactive). Ошибки валидации — баннером `RateLimitErr`. Роуты `POST /domains/{id}/ratelimit`, `POST /applications/{aid}/ratelimit`. Milter читает строку живьём — **reload не нужен**. Сервис-обёртки `RateLimit`/`SaveRateLimit`/`ClearRateLimit` на domain и app сервисах.
- **Проверено на сервере** (selfpost.mixfed.ru, контейнер `p8`): `gofmt`/`vet`/`test` зелёные (юниты: store set/get/upsert/delete, by-name/login, distinct+windowed count с исключением rejected, delete-for-domain, Active/AllowsIP incl. IPv6-форма; milter: reject при домен/app превышении, allow под лимитом, unregistered-IP игнор, инертный без потолка, fail-open на lookup/count-ошибке, no-IP-сессия). Контейнерный e2e (аутентифицированный SMTPS 465 изнутри контейнера, client=127.0.0.1): лимит `max=2` → 3-е письмо `SENDER-REFUSED 451`, ровно 2 в очереди, 1 строка `rejected`; **то же на уровне домена**; **unregistered IP** (198.51.100.1) → уровень 2 не применяется (оба письма прошли при `max=1`); **fail-open** — панель остановлена (`supervisorctl stop panel`) при `max=1` → почта принята; UI обеих форм рендерится. Уровень 1 (anvil) не тронут.
### Сделано в Фазе 7
- **Три экрана мониторинга** (`internal/web/handlers_monitor.go` + шаблоны `sendlog.html`/`queue.html`/`logtail.html` + фрагменты `sendlog_rows.html`/`queue_body.html`/`logtail_body.html`):
- **Журнал отправки** (`/sendlog`): таблица время/домен/приложение/From/To/Subject/статус, серверные фильтры по домену и логину приложения (`WHERE` через `store.SendLogFilter`, параметризовано), пагинация (50/страница, `LIMIT/OFFSET`, счётчик страниц через `CountSendLog`), HTMX-polling каждые 5с (`hx-trigger="every 5s"` на самообновляющемся `<div>`, `hx-swap="outerHTML"` — ответ фрагмента несёт те же hx-атрибуты, поэтому поллинг не обрывается). Вывод экранируется автоматически `html/template` (subject с `<script>` проверен — рендерится как `&lt;script&gt;`).
- **Очередь Postfix** (`/queue`): `postqueue -p` без изменений (`internal/postfix/queue.go`, фикс-аргумент, без shell), HTMX-polling.
- **Хвост mail.log** (`/logtail`): `logtail.TailLines` (`internal/logtail/logtail.go`) — точечное чтение последних 200 строк обратными чанками (не весь файл на каждый polling-тик), независимо от фонового `follow()`-цикла Фазы 6, HTMX-polling.
- **Fragment-эндпоинты** (`GET /sendlog/rows`, `/queue/body`, `/logtail/body`) отдают HTML-куски, не JSON (ТЗ 7.1) — отдельный рендер-путь `renderFragment` в `templates.go` (без layout), тот же `{{define}}`-блок используется и полной страницей при первой загрузке, и фрагментом при обновлении — не расходятся.
- **Store**: `QuerySendLog`/`CountSendLog` (`internal/store/sendlog.go`, общий `sendLogWhere` для обоих), `ListApplicationLogins` (`internal/store/applications.go`) — источник для выпадающего списка приложений в фильтре.
- **Навигация**: ссылки Send log/Queue/Log добавлены в топбар дашборда; на каждой из новых страниц — ссылки на остальные экраны + «Domains».
- **Проверено на сервере** (selfpost.mixfed.ru, контейнер `p7`): `gofmt`/`vet`/`test` зелёные, `docker build` ок; e2e через curl — все три страницы и все три фрагмента 200, фильтр по домену/приложению сужает выборку, несуществующий домен → «No messages logged yet.», пагинация проверена на 60 строках (страница 1/2 = 50/10 строк, ссылки Newer/Older корректны), `postqueue -p` реально выполняется и возвращает «Mail queue is empty», хвост `mail.log` отдаёт реальные строки Postfix, HTML-экранирование подтверждено (`<script>` в Subject → `&lt;script&gt;`), существующая кнопка Reload и остальные экраны не сломаны.
### Сделано в Фазе 6
- **Journal-milter в чистом Go** (`internal/milter/milter.go`, go-milter v0.4.1): сессия на соединение (`NewMilter`), поля собираются по стадиям (SASL-логин `{auth_authen}`+From на `MailFrom`, каждый получатель на `RcptTo`, `Subject` в `Header`, queue-id `{i}` на EOM/`Body`), запись `send_log` со статусом `queued` **по одной на пару (queue-id, получатель)** (ТЗ 7.3.3), домен деривится из From (привязка отправителя это гарантирует). Строго монитор: колбэки возвращают только Continue/Accept, ошибки записи логируются, **никогда не прокидываются** — приём почты не блокируется. `Protocol: OptNoBody`. Per-message reset на `MailFrom` (несколько писем на соединение). Сокет-жизненный цикл (mkdir/удаление stale/`chmod 0660` для группы `selfpost`) в `cmd/panel/journal.go`; `milter.Serve(ctx, ln, store)` слушает до отмены ctx.
- **Log-tailer** (`internal/logtail/logtail.go`): поллинг-хвост `mail.log` (интервал 1с) с обработкой **ротации** (смена inode `os.SameFile` — logrotate `create`; усечение `size<pos``copytruncate`), старт с конца файла (не переигрывать историю), накопитель частичной строки. Regex парсит строки доставки (`QID: to=<addr>, … status=<sent|deferred|bounced|expired>`), `expired→bounced`, обновляет `send_log` по (queue-id, получатель) регистронезависимо (`COLLATE NOCASE` — Postfix может менять регистр). qmgr/smtpd-строки игнорируются.
- **Retention** (там же): фон-задача в роли tailer чистит записи старше `SEND_LOG_RETENTION_DAYS` (дефолт 90) — сразу при старте, затем каждые 6ч. `store.DeleteSendLogBefore` (лексикографическое сравнение RFC3339-UTC).
- **Store** (`internal/store/sendlog.go`): `InsertQueued`, `UpdateStatus` (возвращает кол-во строк — видно, совпал ли лог с записью), `DeleteSendLogBefore`; статусы-константы `queued/sent/deferred/bounced`.
- **Bounded milter-таймауты** (`build/postfix-config.sh`, ТЗ 7.3): `milter_connect/command/content_timeout` = 15/15/30с (env-переопределяемо), чтобы **зависший** milter (сокет принимает, но не отвечает) тоже вёл к fail-open за секунды, а не за дефолтные 300с. Per-milter `default_action=accept` для journal уже стоял (Фаза 5).
- **Проводка ролей** (`cmd/panel/main.go`): store открывается **один раз** в `run()` и шарится всеми ролями (http/milter/tailer) — под `MaxOpenConns(1)`+WAL записи сериализуются. Заглушки `journalstub.go`/`logtailstub.go` удалены. Новая зависимость `github.com/emersion/go-milter v0.4.1` (BSD-2, AGPL-совместима).
- **Найдено и исправлено на сервере:** пустой `app_login` из-за фигурных скобок в именах макросов (см. выше, `macro()` helper).
- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`vet`/`test` зелёные (юниты: store insert/update/case-insensitive/no-match/retention; milter — строка-на-получателя, fail-open при ошибке записи, reset между письмами, braced-макросы, domainOf; logtail — парсер всех статусов + qmgr/smtpd игнор + интеграционный тест хвоста с ротацией). Контейнерный e2e: аутентифицированное письмо (2 получателя) → **2 строки** `send_log` с верными domain/app_login=`mailer`/From/To/Subject/queue-id, статус `queued``bounced` через tailer; **fail-open подтверждён дважды** — (1) milter недоступен (сокет удалён, `warning: connect to Milter … No such file`) → письмо принято, контейнер жив; (2) milter завис (сокет принимает, не отвечает) → приём за ~16с (таймаут 15с `SMFIC_OPTNEG` → accept), не 300с; восстановление панели пересоздаёт сокет (owner `panel`); **retention** — 100-дневная строка вычищена при старте, свежая осталась.
### Сделано в Фазе 5
- **Генерация конфига релея из env** (`build/postfix-config.sh`, вызывается из `entrypoint.sh` под root на каждом старте — не в образе, чтобы cert-пути/лимиты/hostname/587 задавались env и пересобирались как остальное состояние). `main.cf` через `postconf -e`, сервисы `master.cf` через `postconf -M/-P`, финальный `postfix check`.
- **`master.cf`:** `smtps` 465 (implicit TLS, `smtpd_tls_wrappermode=yes`) — основной; `submission` 587 (STARTTLS, `smtpd_tls_security_level=encrypt`) — опционально по `SUBMISSION_ENABLE=true` (иначе удаляется `postconf -MX`). Оба `chroot=n` + `smtpd_client_restrictions=permit_sasl_authenticated,reject`.
- **SASL:** `smtpd_sasl_type=cyrus`, conf `/etc/postfix/sasl/smtpd.conf` (`auxprop`/`sasldb`, `sasldb_path=/data/sasl/sasldb2`). **Рабочий реалм:** `smtpd_sasl_local_domain` пустой + `myhostname=$SELFPOST_HOSTNAME` → клиент логинится «голым» логином, Cyrus резолвит по realm=myhostname (= realm учётки), а `sasl_username` для карты = голый логин = значение карты. Проверено (235 + отправка).
- **Привязка/анти-релей:** `smtpd_sender_login_maps=texthash:/data/postfix/sender_login_maps` + `reject_sender_login_mismatch`; `smtpd_relay_restrictions`/`smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination`; **нет `permit_mynetworks`** — авторизация только по кредам. Open relay невозможен.
- **TLS/доставка:** cert/key из `TLS_CERT_FILE`/`TLS_KEY_FILE`; `smtp_tls_security_level=may` (исходящая, MX-lookup напрямую); суточный `postfix reload` для подхвата обновлённых сертов (`build/postfix-cert-reload.sh`, `[program:cert-reload]` под supervisord, exit≠0 не роняет контейнер).
- **Rate-limit L1:** `smtpd_client_message_rate_limit`/`anvil_rate_time_unit` из env (дефолт 100/3600с).
- **Milter-цепочка (per-milter действия, синтаксис скобок Postfix 3.0):** `{ unix:opendkim.sock, default_action=tempfail }, { unix:journal.sock, default_action=accept }` — OpenDKIM строго (при недоступности → defer), journal fail-open (не блокирует релей, ТЗ 7.3). `non_smtpd_milters` пустой.
- **Два инфра-фикса, найденных на сервере** (см. память [[postfix-chroot-and-milter-socket-perms]]): (1) `postconf -F '*/*/chroot=n'` — chroot-агент доставки не читает `/etc/resolv.conf`, MX-lookup падал «Host not found», письма не уходили; (2) `entrypoint.sh` ставит `/run/opendkim` и `/run/selfpost` в группу `selfpost`+setgid `2750`, journal-stub делает `chmod 0660` на сокет — иначе `postfix` не мог подключиться к milter-сокетам, а строгий OpenDKIM отбивал всю почту (`milter-reject` 451). Journal-stub (`cmd/panel/journalstub.go`) — единственное изменение Go.
- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`vet`/`test` зелёные, образ собирается; контейнерный e2e — 465 auth+отправка **подписано DKIM** (`d=домен; s=selfpost`), 587 STARTTLS auth, кросс-доменный отправитель `553 not owned`, list-режим по адресам, неаутентифицированный релей `554`, **реальная исходящая доставка до MX получателя по TLS** (`relay=mc.mixfed.ru`), DKIM-подпись верифицируется опубликованным ключом (`opendkim-testkey: key OK`).
- **Финальная реальная доставка подтверждена** (`dtester@mixdelta.ru → selfpost@mixeme.ru`, прочитано по IMAP): `dkim=pass (d=mixdelta.ru s=selfpost)` + `spf=pass` в `Authentication-Results` от `mc.mixfed.ru`. Все критерии «Готово когда» Фазы 5 выполнены.
### Сделано в Фазе 4
- **Модель приложения** (`internal/store/applications.go`): CRUD над `applications`/`application_addresses` в транзакциях; `login` глобально уникален (арбитр — UNIQUE, `ErrLoginExists`); `ListBindings` (SQL `UNION ALL`) отдаёт пары «адрес→логин» (wildcard → `@domain`, list → каждый адрес) детерминированно отсортированными — сырьё для карты; `ListLoginsByDomain` для очистки `sasldb2` перед каскадом; `DeleteApplication` возвращает удалённую строку (нужен логин для `sasldb2`).
- **SASL `sasldb2`** (`internal/app/sasl.go`): обёртка над `saslpasswd2` (эквивалент по ТЗ 5.1). `Set` = `-p -c -f <db> -u <realm> <login>`, **пароль только через stdin** (не в argv → не течёт в `ps`/логи), логин — отдельный argv-элемент после строгого whitelist (`validateLogin`, без `@` — это разделитель realm в `sasldb2`), без shell (ТЗ 7.6.3). `Delete` = `-d ...`, идемпотентно. `run` — инъектируемое поле для тестов.
- **Пароль приложения** (`internal/app/password.go`): 24 байта `crypto/rand` → base64url (192 бита), генерируется панелью, показывается **один раз**, plaintext не хранится (ТЗ 7.6.1).
- **Валидация** (`internal/app/validate.go`): `validateLogin` (364, `[A-Za-z0-9._-]`, без `@`); `validateSenderAddress`**критичная проверка ТЗ 7.6.2**: часть после `@` обязана строго равняться домену приложения (проверка ДО записи в конфиг, а не через `sender_login_maps` при доставке); строгий whitelist localpart; `parseAddresses` нормализует/дедуплицирует/требует ≥1 адрес.
- **`smtpd_sender_login_maps`** (`internal/postfix/postfix.go`): полная регенерация файла из всех привязок (чистая функция реестра, идемпотентно, как OpenDKIM-таблицы), атомарная запись (`write.go`, temp+rename). **Many-to-one**: несколько логинов на один адрес сливаются в одну строку `адрес log1,log2` (штатный случай ТЗ 5.1 §4). `assertMapSafe` — backstop против пробелов/переводов строк/запятых/`@` в логине (ТЗ 7.6.4). Тип карты для Фазы 5 — `texthash:` (без `postmap`).
- **Reload Postfix — исправленный механизм** (ключевое инфра-решение): изначальный план «`supervisorctl signal HUP postfix`» **не работает**`postfix start-fg` форкает отдельный master, и сигнал супервизируемому foreground-процессу до master не доходит (в отличие от OpenDKIM, который сам и есть foreground-процесс). Решение: одноразовая supervisord-программа `[program:postfix-reload]` (`command=/usr/sbin/postfix reload`, `autostart=false`, `startsecs=0`, `exitcodes=0`); панель дёргает её `supervisorctl start postfix-reload` через тот же групповой контрол-сокет. Даёт настоящий `postfix reload` от root без привилегий у панели; postfix остаётся RUNNING, программа уходит в EXITED(0), crashexit не срабатывает. Проверено по `mail.log` (`reload -- version`).
- **Сервис приложений** (`internal/app/service.go`): координация store+`sasldb2`+карты. `Create` — сначала строка реестра (арбитр дубля, не даёт затереть чужой пароль в `sasldb2`), затем `sasldb2`, затем rebuild карты + reload; полный откат при сбое любого шага. `UpdateMode`/`Delete`/`RegeneratePassword`/`PurgeDomainSASL`/`Resync`. `SenderMaps` — интерфейс над Postfix для тестируемости.
- **Интеграция с доменами** (`internal/domain/service.go`): `Delete` теперь через интерфейс `Applications` — сначала `PurgeDomainSASL` (пока логины в реестре), затем каскад БД, затем rebuild карты + reload Postfix, затем удаление DKIM-ключа. Ручной reload (7.2.12) теперь перегружает и OpenDKIM, и Postfix.
- **Web** (`internal/web/handlers_apps.go`, шаблон `domain_detail.html`): создание/редактирование режима/перевыпуск/удаление приложения в карточке домена; одноразовый показ пароля рендерится **инлайн** (не через redirect — иначе пароль потерян); серверные ошибки валидации показываются на форме; флаги подтверждения на удаление/перевыпуск.
- **Инфра**: `postfix` добавлен в группу `selfpost` (Dockerfile) для чтения `sasldb2`/карты; `entrypoint.sh` нормализует `/data/sasl` (setgid 2750, `sasldb2` 0640) и `/data/postfix` (файл карты создаётся пустым до старта, 0640) — самолечение прав после restore.
- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`go vet`/`go test` зелёные (юниты: store CRUD/bindings/cascade, рендер карты+инъекции, валидация логина/адреса/принадлежности домену, argv/stdin `saslpasswd2`, сервис create/rollback/delete/mode/regen/purge с фейками); docker build ок; контейнерный e2e — весь жизненный цикл приложения + каскад домена + персистентность через рестарт + **реальный `postfix reload`** из пути создания и кнопки reload.
### Сделано в Фазе 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) зелёные. Проверено на сервере.
- **Фаза 4** (2026-07-12, Opus) — приложения + SASL + привязка к домену: учётки в `sasldb2` через `saslpasswd2` (пароль по stdin, логин whitelisted argv, без shell — 7.6.3), генерируемый пароль показывается один раз (7.6.1), режим адресов wildcard/list с серверной проверкой принадлежности адреса домену (7.6.2), генерация `smtpd_sender_login_maps` (many-to-one слияние, injection-safe — 7.6.4), CRUD приложений + перевыпуск пароля + каскад при удалении домена (очистка `sasldb2` + пересборка карты). **Исправлен reload Postfix:** `signal HUP` не доходит до форкнутого master → одноразовая supervisord-программа `postfix-reload` (настоящий `postfix reload` от root без привилегий панели). `postfix` в группе `selfpost`, `/data/sasl`+`/data/postfix` под setgid. Новые пакеты `internal/app`, `internal/postfix`. Юнит-тесты + контейнерный e2e (весь жизненный цикл, каскад, персистентность, реальный reload по `mail.log`) зелёные.
- **Фаза 5** (2026-07-12…13, Opus) — полный исходящий релей Postfix: `smtps` 465 (wrapper TLS) как основной + опциональный `submission` 587 (STARTTLS), SASL (`cyrus`/`sasldb2`, реалм через пустой `smtpd_sasl_local_domain` + `myhostname`), привязка отправителя (`smtpd_sender_login_maps`+`reject_sender_login_mismatch`), без open relay (только по кредам, нет `permit_mynetworks`), исходящая доставка (MX-lookup, TLS may), rate-limit L1 (`anvil`), milter-цепочка с per-milter действиями (OpenDKIM tempfail / journal accept). Конфиг генерируется из env в `postfix-config.sh` (вызов из entrypoint). Два инфра-фикса на сервере: `postconf -F '*/*/chroot=n'` (chroot ломал DNS доставки) и права milter-сокетов (группа `selfpost`+setgid, `chmod 0660` на journal-сокет). **Реальная доставка подтверждена:** `mixdelta.ru → selfpost@mixeme.ru`, `dkim=pass`+`spf=pass` в `Authentication-Results` (по IMAP). Коммиты `b2692e4` (релей), `ec4d4b9`/этот (доки).
- **Фаза 6** (2026-07-13, Opus) — journal-milter + обновление статусов Send Log (наивысший риск ТЗ 7.3): milter на go-milter v0.4.1 (запись `send_log` на пару queue-id/получатель на EOM, строго fail-open — колбэки только Continue/Accept), log-tailer с ротацией `mail.log` (парс `sent/deferred/bounced/expired` → апдейт по queue-id+получатель), retention (`SEND_LOG_RETENTION_DAYS`=90, чистка при старте+каждые 6ч), bounded milter-таймауты (15/15/30с) для fail-open при зависании. Store открывается один раз и шарится ролями. **Найден и исправлен** пустой `app_login`: имена макросов приходят в фигурных скобках (`{auth_authen}`) — brace-толерантный `macro()`. Юниты + контейнерный e2e зелёные; **fail-open проверен дважды** (недоступность и зависание), retention проверен. Новая зависимость go-milter (BSD-2).
- **Фаза 8** (2026-07-14, Opus) — дифференцированные лимиты (rate limit уровень 2, ТЗ 7.4): journal-milter из чистого монитора стал отклонять письмо `4xx` (`RespTempFail` 451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт — `COUNT(DISTINCT queue_id)` в скользящем окне по `send_log`, применяется только при непустой IP-привязке (иначе только уровень 1). Строго **fail-open** на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутся `send_log` статусом `rejected` для UI. Store `internal/store/ratelimits.go` (таблица `rate_limits` уже была с Фазы 2 — миграции нет), панель-формы на домене и приложении с серверной валидацией IP/чисел (ТЗ 7.6.2), очистка лимитов при каскадном удалении. Юниты + контейнерный e2e (`p8`): реджект на обоих уровнях, unregistered-IP игнор, fail-open при остановке панели — зелёные. Все критерии «Готово когда» Фазы 8 выполнены.
- **Фаза 9** (2026-07-14, Opus) — бэкап/restore + экспорт/импорт домена (ТЗ 7.5, 11.6): полный `tar.gz`-бэкап всего `/data` (консистентный снимок SQLite через `VACUUM INTO`, DKIM-ключи, `sasldb2`, `manifest.json` с версией; без TLS-сертов/`tls/` и очереди Postfix) двумя путями — кнопка `POST /backup` и CLI `selfpost-backup` через `docker exec`; гварда версии `CheckRestore` до `store.Open` (несовпадение → отказ старта с указанием тега; совпадение → манифест потребляется, restore идёт обычным стартом без отдельной ветки). Экспорт/импорт домена: `DomainExport` (DKIM-ключ PKCS#1 PEM + приложения с **рабочими паролями**), секреты SASL читаются из `sasldb2` через `db_dump` (`userPassword` — плейнтекст), на импорте **ре-кеятся под локальный realm** через `saslpasswd2` → креды работают на инстансе с другим hostname без перевыпуска, DKIM DNS-запись не меняется. `db-util` добавлен в образ. Юниты + контейнерный e2e (экспорт↔импорт кросс-realm с проверкой SMTP-auth 235; CLI+кнопка бэкап; restore той же версии; отказ при несовпадении версии) зелёные.
- **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
- **Фаза 11** (2026-07-15, Opus) — финальный проход по безопасности и приёмка (ТЗ 7.6, 12.2–3): построчный аудит всех 8 пунктов раздела 7.6 против кода — **соответствие полное, правок кода не потребовалось** (setup-токен 128-бит/const-time/одноразовый, серверная валидация вкл. импорт-путь, `os/exec` фикс-argv без shell, санитизация записи в конфиги, rate-limit логина+setup, cookie `HttpOnly`/`Secure`/`SameSite=Lax`, только `html/template`, панель под `panel`≠root). Приёмка на сервере: `gofmt`/`vet`/`build`/`test` зелёные, `docker build` ок, **чистый старт контейнера `selfpost:p11`** (все процессы RUNNING, панель Uid 999, setup-ссылка+токен 0600, битый токен→404, unauth→303, healthz→200). Остаточная заметка (не дефект, спец-совместимо): rate-limit кеится по `RemoteAddr` (namеренно без XFF-парсинга) → за прокси лимитеры глобальны; для setup реальная защита — энтропия токена (ТЗ 7.6.1). **Базовый план v1.0 (0→11) завершён.**
- **Фаза 10** (2026-07-15, Sonnet) — деплой + документация (ТЗ 10): `deploy/docker-compose.yml` (Apache на хосте, не в контейнере — фиксированный тег образа, hardening `cap_drop ALL`+точечный `cap_add`, порт 8080 только на `127.0.0.1`) + альтернативные фрагменты nginx/Caddy/Traefik (каждый — валиден через `docker compose config`, относительные пути исправлены под правило «резолвятся от каталога первого `-f`-файла», списки `ports`/`volumes` через YAML-тег `!override`, иначе дополняются, а не заменяются). CI `.github/workflows/release.yml` (тег `vX.Y.Z` → сборка + push в `ghcr.io/mixeme/selfpost`, версия из тега в один `ldflags`+тег образа). **Найден и закрыт пробел с Фазы 1:** `logrotate` был установлен, но никогда не запускался — добавлены `logrotate-mail.conf`(`copytruncate`, т.к. `postlogd` держит `mail.log` открытым и сигнала на ротацию нет)+`logrotate-loop.sh`+supervisor-программа. README переписан (чеклист требований, DNS уровня сервера/домена, прогрев IP, бэкап vs экспорт/импорт домена, обоснование фиксированного тега, требования к машине). Проверено на сервере: `docker build`/`docker run` с новым logrotate — процессы RUNNING, принудительная ротация отработала; `docker compose config` зелёный для всех 4 вариантов. Все критерии «Готово когда» Фазы 10 выполнены.