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

81 KiB
Raw Blame History

Прогресс реализации SelfPost

Живой трекер фаз. Переживает /clear — читается первым при возобновлении работы. План фаз: implementation-plan.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). За обратным прокси (дефолтный 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 каталоги /datachmod: 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, token.go): токен 128 бит crypto/rand (randomToken(16), base64url — 22 симв.), TTL 10 мин с ре-генерацией при истечении/рестарте, сравнение subtle.ConstantTimeCompare, неудачи НЕ инвалидируют токен, одноразовость через наличие строки admin (после успеха /setup/*→404), файл токена 0600, пароль админа только bcrypt (DefaultCost).
    2. Серверная валидация (validate.go, app/validate.go): строгие whitelist для домена (DNS-форма, ≥2 меток, [a-z0-9.-]), логина (без @), localpart; проверка принадлежности адреса домену до записи в конфиг (validateSenderAddress); импорт-путь (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 assertConfigSafe, postfix.go assertMapSafe) — hard-backstop против пробелов/переводов строк/разделителей перед записью KeyTable/SigningTable/sender-map, атомарная запись (temp+rename).
    5. Rate-limit логина (ratelimit.go, 10/15мин) + отдельный на /setup (10/мин).
    6. Сессии/cookie (session.go, handlers_auth.go): токен 256 бит crypto/rand, cookie HttpOnly+Secure(дефолт true)+SameSite=Lax (Lax закрывает CSRF на POST-мутациях — все мутации POST, все GET read-only).
    7. Экранирование вывода (templates.go) — только html/template, ни одного text/template/template.HTML/Safe*-байпаса.
    8. Панель не под root (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 (маршрут скрыт), неаутентифицированный /domains303 /login (auth-middleware), /healthz200. Все критерии «Готово когда» Фазы 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, а не сигнал Postfixmaillog_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.testmail.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<poscopytruncate), старт с конца файла (не переигрывать историю), накопитель частичной строки. 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, статус queuedbounced через 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 vs.
  • Сервис/веб (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 выполнены.