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>
79 KiB
Прогресс реализации SelfPost
Живой трекер фаз. Переживает /clear — читается первым при возобновлении работы.
План фаз: implementation-plan.md. ТЗ: specification.md.
Как возобновить после сброса контекста
- Прочитать этот файл (текущая фаза, статус, что сделано, что дальше).
- Прочитать соответствующую фазу в
implementation-plan.md. - При необходимости — детали в
specification.md. - Продолжить с пункта «Следующий шаг».
Рекомендуемая модель по фазам
| Фаза | Модель | Почему |
|---|---|---|
| 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:
- Обновляет этот файл: статус фазы → ✅, заполняет «Сделано» и «Следующий шаг».
- Проверяет критерии «Готово, когда…» из плана.
- Пишет одну строку в журнал ниже.
- Делает финальный коммит фазы.
- Явно говорит: «Фаза 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. - Прежняя фаза: 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. DNSmixdelta.ru(A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящикеselfpost@mixeme.ruостались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
Сделано в Фазе 11
- Финальный проход по безопасности (ТЗ 7.6) — построчный аудит кода по всем 8 пунктам, все подтверждены:
- Setup secret-link (setup.go, token.go): токен 128 бит
crypto/rand(randomToken(16), base64url — 22 симв.), TTL 10 мин с ре-генерацией при истечении/рестарте, сравнениеsubtle.ConstantTimeCompare, неудачи НЕ инвалидируют токен, одноразовость через наличие строки admin (после успеха/setup/*→404), файл токена0600, пароль админа только bcrypt (DefaultCost). - Серверная валидация (validate.go, app/validate.go): строгие whitelist для домена (DNS-форма, ≥2 меток,
[a-z0-9.-]), логина (без@), localpart; проверка принадлежности адреса домену до записи в конфиг (validateSenderAddress); импорт-путь (handlers_backup.go) —MaxBytesReader1 MiB +DisallowUnknownFields+normalizeDomain/validateDomain, аImportApplicationвалидирует login/адреса/пароль (validateImportedPassword— непустой, ≤1024, без управляющих). os/execбез shell — все 5 сайтов фикс-argv, безsh -c:saslpasswd2(пароль по stdin, логин whitelisted argv),db_dump,postqueue -p, дваsupervisorctl(reload OpenDKIM/Postfix). Пользовательский ввод в аргументы команд не попадает вообще (кроме whitelisted логина).- Санитизация записи в конфиги (opendkim.go
assertConfigSafe, postfix.goassertMapSafe) — hard-backstop против пробелов/переводов строк/разделителей перед записью KeyTable/SigningTable/sender-map, атомарная запись (temp+rename). - Rate-limit логина (ratelimit.go, 10/15мин) + отдельный на
/setup(10/мин). - Сессии/cookie (session.go, handlers_auth.go): токен 256 бит
crypto/rand, cookieHttpOnly+Secure(дефолт true)+SameSite=Lax(Lax закрывает CSRF на POST-мутациях — все мутации POST, все GET read-only). - Экранирование вывода (templates.go) — только
html/template, ни одногоtext/template/template.HTML/Safe*-байпаса. - Панель не под root (supervisord.conf
[program:panel] user=panel; root только в entrypoint-бутстрапе и supervisord-PID1; межпользовательский доступ — через группуselfpost+setgid, не через привилегии).
- Setup secret-link (setup.go, token.go): токен 128 бит
- Приёмка ТЗ 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-reloadSTOPPED— штатный on-demand one-shot),restarts=0; панель под Uid/Gid 999 (panel, не root) проверено по/proc/<pid>/status; setup-ссылка напечатана в лог с 128-бит токеном,/data/setup-token0600 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.logtruncated,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_keyPKCS#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 на stdinsaslpasswd2).
- Секреты SASL:
- Проверено на сервере (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) импорт на другой инстанс (hostnamemail.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) несовпадение версии (manifest1.3.0vs бинарник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/получателя (отклонено до постановки в очередь).
- Когда лимит применяется: только если у домена/приложения заданы непустой список IP и потолок сообщений и окно, и client IP входит в этот список (
- 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>проверен — рендерится как<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.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 →<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с) с обработкой ротации (смена inodeos.SameFile— logrotatecreate; усечение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-milterdefault_action=acceptдля journal уже стоял (Фаза 5). - Проводка ролей (
cmd/panel/main.go): store открывается один раз вrun()и шарится всеми ролями (http/milter/tailer) — подMaxOpenConns(1)+WAL записи сериализуются. Заглушкиjournalstub.go/logtailstub.goудалены. Новая зависимостьgithub.com/emersion/go-milter v0.4.1(BSD-2, AGPL-совместима). - Найдено и исправлено на сервере: пустой
app_loginиз-за фигурных скобок в именах макросов (см. выше,macro()helper). - Проверено на сервере (selfpost.mixfed.ru):
gofmt/vet/testзелёные (юниты: store insert/update/case-insensitive/no-match/retention; milter — строка-на-получателя, fail-open при ошибке записи, reset между письмами, braced-макросы, domainOf; logtail — парсер всех статусов + qmgr/smtpd игнор + интеграционный тест хвоста с ротацией). Контейнерный e2e: аутентифицированное письмо (2 получателя) → 2 строкиsend_logс верными domain/app_login=mailer/From/To/Subject/queue-id, статусqueued→bouncedчерез tailer; fail-open подтверждён дважды — (1) milter недоступен (сокет удалён,warning: connect to Milter … No such file) → письмо принято, контейнер жив; (2) milter завис (сокет принимает, не отвечает) → приём за ~16с (таймаут 15сSMFIC_OPTNEG→ accept), не 300с; восстановление панели пересоздаёт сокет (ownerpanel); 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:smtps465 (implicit TLS,smtpd_tls_wrappermode=yes) — основной;submission587 (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+setgid2750, journal-stub делаетchmod 0660на сокет — иначеpostfixне мог подключиться к milter-сокетам, а строгий OpenDKIM отбивал всю почту (milter-reject451). 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(SQLUNION 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,sasldb20640) и/data/postfix(файл карты создаётся пустым до старта, 0640) — самолечение прав после restore. - Проверено на сервере (selfpost.mixfed.ru):
gofmt/go vet/go testзелёные (юниты: store CRUD/bindings/cascade, рендер карты+инъекции, валидация логина/адреса/принадлежности домену, argv/stdinsaslpasswd2, сервис 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.ExecReloadopendkim =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переведён Modev→s. - Сервис/веб (
internal/domain/service.go,internal/store/domains.go,internal/web/handlers_domains.go): Add (реестр→ключ→rebuild→reload, откат строки при сбое; существующий ключ переиспользуется, не перегенерируется — иначе сломается опубликованный DNS), Delete (каскад приложений через FKON 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+сессия): три процесса живы, addExample.COM→нормализация→ключ+таблицы+TXT, opendkim читает ключ панели и остаётся RUNNING после reload, рестарт → ключ и таблицы персистентны (хэш совпал), delete → каскад, удаление ключа, пустые таблицы, opendkim reload; лог панели без ошибок.
Сделано в Фазе 2
- SQLite-персистентность (
internal/store): драйверmodernc.org/sqlite(чистый Go, без cgo — статик-бинарник сохранён; первые сторонние зависимости → появилисьgo.modrequire +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 бит; cookieHttpOnly/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.js2.0.4 (internal/web/static, отдаётся с/static/). - Entrypoint для bind-mount (
build/entrypoint.sh):/data— host bind mount → приходит от root, а панель работает под непривилегированнымpanel(uid 999). Точка входа под root чинит владельца/dataпередexec supervisord(иначе SQLiteunable 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 с cookieHttpOnly; 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-listenercrashexit.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временно Modev(без ключей; Modes+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:
smtps465 (wrapper TLS) как основной + опциональныйsubmission587 (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(RespTempFail451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт —COUNT(DISTINCT queue_id)в скользящем окне поsend_log, применяется только при непустой IP-привязке (иначе только уровень 1). Строго fail-open на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутсяsend_logстатусомrejectedдля UI. Storeinternal/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и CLIselfpost-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, cookieHttpOnly/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 на хосте, не в контейнере — фиксированный тег образа, hardeningcap_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 выполнены.