Keeps implementation-plan.md focused on unresolved v1.0/v1.x questions; inbound relay (Phase O1) and the domain-admin role now live in docs/roadmap.md, cross-linked from progress.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
34 KiB
План реализации: SelfPost
Статус: выполненные фазы здесь не описываются — текущее состояние в
progress.md, история сделанного в CHANGELOG.md
и git log. Ниже остаётся только то, что ещё не сделано для линии
v1.0/v1.x: открытые вопросы для согласования. Объём релизной линии 2.x.x
(входящий релей, роль администратора домена) вынесен в
roadmap.md.
Основа: specification.md v1.0.
Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)
Ниже — то, что выходит за букву ТЗ, но заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого не является дефектом соответствия; это осознанные компромиссы и потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», принимается пользователем.
Раздел A (принятые риски безопасности) переехал в
security.md — риск не задача, а решение, и в плане несделанной
работы ему делать нечего. Буквы разделов и сквозная нумерация пунктов ниже
оставлены как были: на них ссылаются progress.md, коммиты и обсуждения.
B. Надёжность и эксплуатация
-
Сессии — решено: хранить в БД, скользящий срок бездействия. Прежнее поведение (только в памяти, absolute TTL 12 ч) заменяется на:
- таблица
sessionsв SQLite (миграция0002) — вход переживает рестарт, редеплой и восстановление из полного бэкапа. В БД лежит SHA-256 от токена, а не сам токен: украденный файл БД или архив бэкапа во вход не превращается, зато браузер, у которого есть исходная cookie, работает и после восстановления; - срок — скользящий, 7 дней бездействия, задаётся
PANEL_SESSION_IDLE_DAYS(целое число дней, какSEND_LOG_RETENTION_DAYS). Абсолютного потолка нет сознательно: у админа, заходящего регулярно, сессия живёт неограниченно долго; - мониторинговые опросы сессию не продлевают. Четыре фрагмента (
/status/fragment,/queue/body,/logtail/body,/sendlog/rows) опрашивают серверevery 5s; продлевай их — и забытая открытая вкладка держала бы вход вечно, а «7 дней бездействия» означало бы «7 дней без открытой вкладки». Активностью считается переход по странице или действие, то есть всё, кроме GET-запросов с заголовкомHX-Request; Max-Agecookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик);- смена пароля завершает все сессии, включая ту, из которой её делают → редирект на
/login.
Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк.
- таблица
-
Ротация
mail.log— решено: отказаться отcopytruncateв пользу «переименовать +postfix reload». Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки —UpdateStatusвызывается только из internal/logtail, больше ниоткуда. Значит потерянная строкаstatus=sent— это строка журнала отправки, навсегда застрявшая вqueued, то есть тихая порча данных, а не пробел в мониторинге. Окон потери приcopytruncateдва:- до одного интервала опроса (1 с) строк — записанное после последнего
drain()и доtruncateфизически сохранено вmail.log.1, но дескриптор тейлера смотрит на уже обрезанный inode и это пропускает. Это доминирующее окно; - миллисекунды между «
cpдочитал до EOF» иtruncate— эти строки не попадают никуда; дляcopytruncateустранить нельзя.
Решение — ротация переименованием, ровно та механика, которую применяет сам Postfix в
postfix logrotate(mv, затемHUPмастеру): rename атомарен, postlogd продолжает писать в переименованный inode до перезапуска, а тейлер держит дескриптор на том же inode и дочитывает хвост перед переключением на новый файл. Не теряется ничего ни на стороне записи, ни на стороне чтения. Правки:- build/logrotate-mail.conf: убрать
copytruncate, добавитьpostrotate /usr/sbin/postfix reload endscript.rotate 14/compress/delaycompressостаются: удержание N файлов (ТЗ 9) — за logrotate, поэтому берётся не самpostfix logrotate(у него нет retention, он лишь переименовывает с меткой времени и жмёт), а его механика.create 0644 root root, неnocreate— см. стендовую проверку ниже, предположение оnocreateне подтвердилось; follow()в internal/logtail/logtail.go: при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно междуdrain()и проверкой смены файла. Проверкаni.Size() < posсохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом;readLogTail()в internal/web/handlers_monitor.go:fs.ErrNotExist— не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум.
Цена: один
postfix reloadв сутки — ровно то, что уже делает postfix-cert-reload.sh ради сертификатов, никакой новой машинерии.Проверено на живом 1.0.0 до принятия решения: Postfix 3.7.11 (команда
postfix logrotateесть начиная с 3.4, её реализация вpostfix-script— этоmv+master -t || kill -HUP+sleep 1+ компрессор); postlogd работает под uidpostfix,/var/logпринадлежит root,/var/log/mail.log—root:root 0644и пользователюpostfixна запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона.Стендовая проверка при реализации (обязательная по плану) обнаружила, что предположение о
nocreateневерно. На живом контейнере (selfpost.example.com, тестовый образ) послеmv+postfix reloadфайл действительно появляется — но не сразу и не на0644: сам HUP лог не пересоздаёт, это происходит лениво при следующей фактической записи, и создаётся он с режимом0600— непривилегированная панель (свой uid) такой файл читать не может, то есть просмотрmail.logв панели остаётся сломан до следующего холодного старта контейнера (там0644берётся из другого, не связанного с этим, пути создания). Решение — то, что план заранее указал как запасной вариант:create 0644 root rootвместоnocreate. logrotate создаёт пустой файл на 644 сразу послеmv, ещё до запускаpostrotate, и Postfix при следующей записи просто открывает и дозаписывает уже существующий файл, не трогая его режим. Проверено многократно на стенде: после ротации файл сразу (без окна) читаем непривилегированным uid панели, и остаётся на 644 после того, как в него попадает новый трафик.Смежное, не решённое (тот же класс потерь, вариантом выше не лечится): при рестарте панели
follow()стартует с конца файла, поэтому строки, записанные пока она не читала, пропускаются; при редеплоеmail.logисчезает вместе с контейнером —/var/logне в volume. В обоих случаях статусы писем, бывших в полёте, остаютсяqueuedнавсегда — вероятно, чаще, чем при ротации. Кандидаты, если решим закрывать: переживать рестарт (запоминать позицию), вынести лог в/data, либо досверять зависшие строки поpostqueue. - до одного интервала опроса (1 с) строк — записанное после последнего
-
Поведение при незаданном
SELFPOST_HOSTNAME— решено: фатальная проверка в entrypoint.sh с развёрнутым текстом ошибки. Прежнее предложение («предупреждать громко в лог, но не падать») отклонено: оба отказа мягкого fallback'а тихие и отложенные, а предупреждение в лог для таких отказов не работает — оно печатается при старте, а последствие проявляется через часы и в другом месте.Что на самом деле ломает мягкий fallback (формулировка «падают в
localhost» занижала проблему — два fallback'а расходятся между собой):- панель берёт realm как
SASL_REALM→SELFPOST_HOSTNAME→localhost(main.go:128), аpostfix-config.shберётmyhostnameкакSELFPOST_HOSTNAME→hostname -f, то есть ID контейнера (postfix-config.sh:21). При пустомsmtpd_sasl_local_domainCyrus резолвит голый логин против realm =myhostname(механика проверена на стенде в Фазе 5), значит аккаунты пишутся в один realm, а ищутся в другом → аутентификация не работает ни для одного приложения, при полностью зелёной панели, а видно это только как535в чужом приложении. Само расхождение выведено из проверенной механики, отдельно не воспроизводилось; EHLO= ID контейнера: не FQDN, не совпадает с PTR, SPF-проверка HELO падает → почта, если она всё же уходит, попадает в спам. Отказ, не видимый вообще нигде;- побочная ловушка того же класса:
SASL_REALMчитает только панель,postfix-config.shпро него не знает — задать одинSASL_REALMбезSELFPOST_HOSTNAMEломает так же.
Почему фатально, а не мягко. Это не настройка с разумным умолчанием, а идентичность, обязанная одновременно совпасть с PTR/rDNS, с CN/SAN сертификата и с SASL-realm (ТЗ 5.2 п.3, 8) — значения, удовлетворяющего всем трём, угадать нельзя, поэтому любой fallback заведомо неверен. Ужесточением это не является: штатный деплой уже требует переменную (
${SELFPOST_HOSTNAME:?...}, docker-compose.yml:27), CI контейнер не поднимает (test.yml — толькоvet/test), а локальный запуск стоит одной явной переменной (SELFPOST_HOSTNAME=localhost). Меняется поведение только вне поставляемого compose (docker run, k8s, свой compose).Правки:
entrypoint.sh: проверка доpostfix-config.shи доsupervisord; при пустом значении —exit 1. Текст ошибки развёрнутый, а неSELFPOST_HOSTNAME is required: что это за имя, почему обязательно (PTR + CN/SAN + SASL realm), пример значения, где задаётся (.env). Это и есть замена «баннера в панели» — объяснительность там, где её реально прочитают (выводdocker compose up);- там же — синтаксическая проверка значения: минимум одна точка, без схемы, порта и пробелов. Ловит типовые
https://mail.example.comиmail.example.com:465, которые realm не ломают (обе стороны берут одну переменную), но ломают HELO и совпадение с сертификатом, то есть дают тот же тихий спам-отказ; saslRealm()(main.go:128) и fallback вpostfix-config.shоставляем как есть: после гейта в контейнере эти ветки мертвы, а вне контейнера (запуск бинаря локально, без Postfix) расходиться не с чем. Существующие «(SELFPOST_HOSTNAME is not set)» на статус-странице иPTR: unknown(server.go:21) тоже остаются — они как раз про этот случай.
Почему отклонён вариант «панель поднимается с баннером, фатально только для почтового тракта» (обсуждался как более мягкий):
- он отменяет инвариант Фазы 4 — listener
crashexitроняет весь контейнер, когда любой managed-процесс уходит в FATAL, именно чтобы не оставался «живой контейнер с мёртвым компонентом» (crashexit.py). В наивной реализации он в фатальный вариант и вырождается, только на минуту позже и с тремя циклами ретраев в логе; - у него нет обычного оправдания degraded-режима — «починить на живую». Для TLS-сертификата degraded-режим выбран сознательно (файл можно доложить в mount,
cert-reloadподхватит без рестарта), а hostname вшивается вmyhostnameпри генерации конфига, поэтому лечение всё равно = правка.env+ пересоздание контейнера; - главное: в таком состоянии панель остаётся полноценным писателем состояния, и состояние будет неверным. Созданные аккаунты лягут в realm
localhost, а после задания hostname и рестартаSecret()иDeleteищут по паре (login, realm) (sasl.go:97, sasl.go:67) → экспорт их не видит, удаление приложения оставляет сироту вsasldb2навсегда, а в панели они выглядят существующими. Чтобы это было безопасно, пришлось бы ещё блокировать записывающие действия — третий режим работы вместо одной проверки; - канал доставки баннера испорчен ровно тем условием, о котором он предупреждает: адрес панели на первом запуске берётся из setup-ссылки, а она в этом сценарии печатается как
https://localhost/setup/<token>(setup.go:124); HEALTHCHECKв образе не объявлен, поэтому «панель жива, почта мертва» для внешнего мониторинга выглядит здоровым контейнером, а crash-loop виден любой системе.
Цена: контейнер без
SELFPOST_HOSTNAMEне стартует — это и есть цель. Проверка на стенде при реализации: контейнер без переменной падает с ожидаемым текстом и не уходит в бесконечный тихий retry; обычный деплой из deploy/docker-compose.yml не меняется; значение с портом/схемой отклоняется. Обязательность отразить в README иdeploy/.env.example(там переменная уже первая в списке). - панель берёт realm как
C. CI и тесты
-
Интеграционный e2e — решено: герметичный контейнерный прогон отдельным Go-модулем, гейт перед публикацией образа. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории; автоматизируется по сути тот же сценарий.
Что закрывается. Не «интеграция вообще», а один класс отказов — обвязка контейнера, невидимая для
go test: все 22 тестовых файла фейкуют границу процесса (saslpasswd2подменён хукомs.run— sasl_test.go, milter гоняется противfakeRecorder, web — черезhttptest), реальные Postfix/OpenDKIM/supervisord/sasldb2 не стартуют нигде. Исторически ломалось ровно здесь: chroot ломал DNS (postfix-config.sh:168), Postfix не доставал до milter-сокетов (общая группа + setgid, entrypoint.sh:62), расходился SASL-realm (535для всех приложений), reload Postfix сигналом не работал (потребовалась one-shot program), а недостаточныйcap_addв docker-compose.yml уронил контейнер в проде. Общее свойство всех пяти: панель зелёная, юнит-тесты зелёные, тракт мёртв.Что автоматизировать нельзя и не пытаемся: реальные PTR/rDNS, сертификат LE, репутация IP, доставка во внешний ящик (исходящий 25 на GitHub-раннерах закрыт). Это остаётся ручной проверкой на проде.
Форма:
test/e2e/— отдельный Go-модуль со своимgo.mod:go test ./...не подхватывает его без build-тегов, а тестовые зависимости (проверка DKIM-подписи и прочее) не попадают в граф основного модуля, где сейчас три прямых зависимости;- стенд — поставляемый deploy/docker-compose.yml плюс override, а не отдельный тестовый compose: иначе
cap_drop/cap_add/no-new-privileges— ровно то, что сломалось в проде, — останутся непроверенными. Override задаёт самоподписанный сертификат,PANEL_COOKIE_SECURE=false, тестовыйSELFPOST_HOSTNAME, заниженныйRATE_LIMIT_MESSAGES_PER_IP, резолвер (dns:) и высокие порты вместо 465/587/8080 — на dev-сервере они заняты продом, и без этогоmake e2eтам падал бы на конфликте портов, работая при этом в CI; - герметичная почта: фиктивная DNS-зона (CoreDNS/dnsmasq) + sink-MX на
smtp-sinkиз пакета postfix — новых зависимостей ноль. DKIM-запись тест берёт из панели и сам публикует в зону, поэтому попутно проверяется, что запись, которую панель печатает пользователю, вообще рабочая; - детерминизм обязателен: никаких
sleep N, только опрос с таймаутом. Плавающий гейт перестают чинить, и тогда он хуже отсутствующего.
Объём проверок. Позитив: старт контейнера (автостартующие
opendkim/panel/postfix/cert-reload/logrotateвRUNNING,postfix-reload—NOT STARTED, онautostart=false) → setup по токену из/data/setup-token→ login → домен → приложение → SMTP AUTH на 465 → письмо доставлено на sink → подпись проверяется против ключа из зоны → строка журнала переходитqueued → sent(это же покрываетlogtail, а после B.2 — и ротацию). Негативы:- отправка без AUTH — отказ;
- чужой отправитель при валидном AUTH — отказ (
reject_sender_login_mismatch, ТЗ 5.1 п.3); - relay на чужой домен — отказ (
reject_unauth_destination), прямая проверка «не open relay»; - L1-лимит по IP (заниженный в override) — отказ после исчерпания;
- L2-лимит, выставленный через панель, с записью
rejected— заодно путь панель→БД→milter; - fail-open journal-milter'а:
supervisorctl stop panel→ письмо всё равно принято, контейнер жив. Это выполнимо, потому чтоcrashexitподписан только наPROCESS_STATE_FATAL(supervisord.conf:122), а штатный stop даётSTOPPED; - пустой и синтаксически неверный
SELFPOST_HOSTNAME— контейнер падает с ожидаемым текстом (проверка из B.3); - сессия переживает
docker restart(проверка из B.1).
Вне объёма: «зависший», а не упавший milter — требует подставного сокета внутри контейнера, это уже chaos-тест ради одного таймаута.
Запуск. Основной путь —
make e2eна dev-сервере перед тегированием (там и так идёт вся сборка) плюсworkflow_dispatch. В CI прогон вешается на тегvX.Y.Zи блокирует публикацию образа; на обычный push не вешается —vet/testв test.yml остаются как есть. Уведомления специально не настраиваются: признак провала — отсутствие образа вghcrпосле тега, смотрится вкладкой Actions.Переработка release.yml — следствие требования покрыть обе архитектуры. Сейчас multi-arch собирается через qemu; гонять под эмуляцией полный стек Postfix мучительно долго, поэтому релиз переезжает на нативную сборку по архитектурам: job
prepare(единственная точка деривации версии из тега — на ней держится инвариант ТЗ 7.5.А) → матрица[ubuntu-latest, ubuntu-24.04-arm], в каждой сборка--load→ e2e → push per-arch тегаX.Y.Z-amd64/X.Y.Z-arm64→ jobmerge:docker buildx imagetools create -t …:X.Y.Z.setup-qemu-actionуходит,provenance: falseсохраняется. Порядок «сначала тест, потом push» выбран ради того, чтобы публиковались ровно те байты, которые прогонялись; альтернатива (push по digest → тест → сборка манифеста) даёт ту же гарантию, но оставляет в registry мусорные untagged-манифесты после красного прогона и требует второго пути дляworkflow_dispatch. Per-arch теги остаются в registry побочным продуктом; неизменяемость версионного тега (ТЗ 10.1) это не нарушает. Бесплатные arm-раннеры доступны, потому что зеркалоgithub.com/mixeme/selfpostпубличное.Цена: ~10–15 минут на релиз; переработка релизного workflow, который сейчас работает; новый модуль и compose-override на сопровождении. Проверка при реализации:
make e2eна dev-сервере проходит, не задевая прод-порты; красный e2e действительно не даёт опубликовать образ — проверяется одноразовым тегом на заведомо сломанном прогоне (тег и per-arch пакеты после проверки удалить).Порядок работ: сначала B.1–B.3 (они полностью специфицированы, иначе харнесс пришлось бы переписывать под них), затем харнесс — и стендовые проверки B.1/B.3 переезжают в него постоянными регрессиями (пункты 7–8 выше), а не выбрасываются после однократного прогона.
D. Ревизия безопасности
-
Проверка на уязвимости моделью Fable — решено: отдельный проход после B.1–B.3 и C.4, до тега релиза.
Почему после всех четырёх, а не по ходу каждого. Каждый пункт трогает ровно ту поверхность, которую аудит ТЗ 7.6 на v1.0 видел в другом виде: B.1 переписывает аутентификацию (сессии в SQLite, SHA-256 от токена, скользящее продление, разлогин всех при смене пароля), B.2 меняет обращение с дескриптором лога и вешает
postfix reloadна logrotate, B.3 добавляет разбор значения переменной в shell до старта supervisord, C.4 приносит переработанный релизный workflow и compose-override с сознательно ослабленными настройками (PANEL_COOKIE_SECURE=false, самоподписанный сертификат, заниженные лимиты), которому нельзя утечь в прод. Ревизия по пунктам дала бы четыре среза, а смотреть надо итоговое состояние — и заведомо один раз, а не четыре.Объём. Диф от тега
v1.0.0до состояния перед следующим тегом целиком — то есть вместе с Фазами 12–14, которых в аудите v1.0 не было, — плюс повторный проход по чек-листу ТЗ 7.6, а не только по изменённым строкам: регресс в 7.6 возможен и в нетронутом коде, если рядом поменялся вызывающий. Приоритет задаёт то, что панель публично доступна (ТЗ 2.4): аутентификация и сессии, валидация ввода, запись в конфиги и map-файлы (injection),os/execбез shell, права на файлы в/data, обращение с секретами (пароли приложений,sasldb2, архив бэкапа).Модель — Fable, и это сознательно не Opus: B и C пишет Opus, а проверка собственной работы систематически слабее независимой. Правило progress.md «безопасность/инфра → Opus» этим не отменяется — оно про написание кода, здесь речь про ревизию. Форма прогона:
/security-reviewпо изменениям, пока они ещё в ветке (скилл смотрит диф), плюс отдельный ручной проход по 7.6 целиком.Что с находками. Каждая закрывается явно: правка до тега либо запись в security.md как принятый риск с обоснованием (там уже два таких). «Посмотрели и ладно» закрытием не считается. Гейт: вместе с e2e из C.4 — до тега; находка класса «эксплуатируется снаружи» тег откладывает.
E. Указатель на объём 2.x
- Входящий релей, pluggable-антиспам и роль администратора домена вынесены целиком в roadmap.md (линия 2.x.x, вне v1.0/v1.x, только по согласованию — ТЗ 12.6). Здесь оставлен лишь этот пункт-напоминание, что это сознательно отложенный объём, а не забытый.