65a420d230
Line-by-line audit of all 8 points of spec 7.6 against the code: full compliance, no code changes required. Acceptance verified on the dev server (image selfpost:p11): gofmt/vet/build/test green, docker build ok, clean container start (all processes RUNNING, panel as non-root uid 999, setup link + 0600 token, bogus token 404, unauth 303, healthz 200). Baseline v1.0 plan (phases 0->11) complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
80 KiB
Markdown
206 lines
80 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».
|
||
|
||
---
|
||
|
||
## Текущее состояние
|
||
|
||
- **Текущая фаза:** 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.
|
||
- **Прежняя фаза:** 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@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
|
||
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
|
||
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (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.example.com, образ `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.example.com): `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.example.com, образ `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.example.com, контейнер `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>` проверен — рендерится как `<script>`).
|
||
- **Очередь 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.example.com, контейнер `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 → `<script>`), существующая кнопка 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.example.com): `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.example.com): `gofmt`/`vet`/`test` зелёные, образ собирается; контейнерный e2e — 465 auth+отправка **подписано DKIM** (`d=домен; s=selfpost`), 587 STARTTLS auth, кросс-доменный отправитель `553 not owned`, list-режим по адресам, неаутентифицированный релей `554`, **реальная исходящая доставка до MX получателя по TLS** (`relay=mx.example.net`), DKIM-подпись верифицируется опубликованным ключом (`opendkim-testkey: key OK`).
|
||
- **Финальная реальная доставка подтверждена** (`dtester@test.example.org → selfpost@mixeme.ru`, прочитано по IMAP): `dkim=pass (d=test.example.org s=selfpost)` + `spf=pass` в `Authentication-Results` от `mx.example.net`. Все критерии «Готово когда» Фазы 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` (3–64, `[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.example.com): `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.example.com): `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.example.com): `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.example.com): `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, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → `rsync` дерева на сервер → `go build`/`go vet`/`docker build`/тесты там. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).
|
||
|
||
## Утверждённые решения
|
||
|
||
- 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-сокет). **Реальная доставка подтверждена:** `test.example.org → 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 выполнены.
|