Files
selfpost/docs/implementation-plan.md
T
mix 8c95192a7a feat: implement B.2 — rotate mail.log by rename + postfix reload
Replaces copytruncate with rename + `postfix reload` (the same mechanism
`postfix logrotate` itself uses), closing the up-to-one-second window where
copytruncate could drop in-flight delivery lines and leave a send-log row
stuck at "queued" forever.

logrotate-mail.conf keeps `create 0644 root root` rather than `nocreate` as
originally planned: verified on a live container that Postfix recreates the
file itself only lazily, on the next write after reload, and at mode 0600 —
unreadable by the unprivileged panel process. `create` hands the file back at
0644 immediately after rename, before Postfix ever touches it.

logtail.follow() re-drains the old file descriptor once more right before
switching to the rotated file, closing the residual gap between the last
poll's drain and the rotation check. readLogTail() treats a momentarily
missing mail.log as an empty screen rather than a logged error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:45:15 +03:00

50 KiB
Raw Blame History

План реализации: SelfPost

Статус: выполненные фазы здесь не описываются — текущее состояние в progress.md, история сделанного в CHANGELOG.md и git log. Ниже остаётся только то, что ещё не сделано: открытые вопросы для согласования и опциональная линия 2.x.x.

Основа: specification.md v1.0.


Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)

Ниже — то, что выходит за букву ТЗ, но заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого не является дефектом соответствия; это осознанные компромиссы и потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», принимается пользователем.

Раздел A (принятые риски безопасности) переехал в security.md — риск не задача, а решение, и в плане несделанной работы ему делать нечего. Буквы разделов и сквозная нумерация пунктов ниже оставлены как были: на них ссылаются progress.md, коммиты и обсуждения.

B. Надёжность и эксплуатация

  1. Сессии — решено: хранить в БД, скользящий срок бездействия. Прежнее поведение (только в памяти, 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-Age cookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик);
    • смена пароля завершает все сессии, включая ту, из которой её делают → редирект на /login.

    Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк.

  2. Ротация 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 работает под uid postfix, /var/log принадлежит root, /var/log/mail.logroot:root 0644 и пользователю postfix на запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона.

    Стендовая проверка при реализации (обязательная по плану) обнаружила, что предположение о nocreate неверно. На живом контейнере (selfpost.mixfed.ru, тестовый образ) после 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.

  3. Поведение при незаданном SELFPOST_HOSTNAME — решено: фатальная проверка в entrypoint.sh с развёрнутым текстом ошибки. Прежнее предложение («предупреждать громко в лог, но не падать») отклонено: оба отказа мягкого fallback'а тихие и отложенные, а предупреждение в лог для таких отказов не работает — оно печатается при старте, а последствие проявляется через часы и в другом месте.

    Что на самом деле ломает мягкий fallback (формулировка «падают в localhost» занижала проблему — два fallback'а расходятся между собой):

    • панель берёт realm как SASL_REALMSELFPOST_HOSTNAMElocalhost (main.go:128), а postfix-config.sh берёт myhostname как SELFPOST_HOSTNAMEhostname -f, то есть ID контейнера (postfix-config.sh:21). При пустом smtpd_sasl_local_domain Cyrus резолвит голый логин против 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 (там переменная уже первая в списке).

C. CI и тесты

  1. Интеграционный e2e — решено: герметичный контейнерный прогон отдельным Go-модулем, гейт перед публикацией образа. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории; автоматизируется по сути тот же сценарий.

    Что закрывается. Не «интеграция вообще», а один класс отказов — обвязка контейнера, невидимая для go test: все 22 тестовых файла фейкуют границу процесса (saslpasswd2 подменён хуком s.runsasl_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-reloadNOT STARTED, он autostart=false) → setup по токену из /data/setup-token → login → домен → приложение → SMTP AUTH на 465 → письмо доставлено на sink → подпись проверяется против ключа из зоны → строка журнала переходит queued → sent (это же покрывает logtail, а после B.2 — и ротацию). Негативы:

    1. отправка без AUTH — отказ;
    2. чужой отправитель при валидном AUTH — отказ (reject_sender_login_mismatch, ТЗ 5.1 п.3);
    3. relay на чужой домен — отказ (reject_unauth_destination), прямая проверка «не open relay»;
    4. L1-лимит по IP (заниженный в override) — отказ после исчерпания;
    5. L2-лимит, выставленный через панель, с записью rejected — заодно путь панель→БД→milter;
    6. fail-open journal-milter'а: supervisorctl stop panel → письмо всё равно принято, контейнер жив. Это выполнимо, потому что crashexit подписан только на PROCESS_STATE_FATAL (supervisord.conf:122), а штатный stop даёт STOPPED;
    7. пустой и синтаксически неверный SELFPOST_HOSTNAME — контейнер падает с ожидаемым текстом (проверка из B.3);
    8. сессия переживает 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 → job merge: 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. Ревизия безопасности

  1. Проверка на уязвимости моделью 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

  1. Входящий релей и pluggable-антиспам вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.

  2. Роль администратора домена — кандидат на 2.x, вне объёма v1.x. Прежняя формулировка пункта («2FA и несколько администраторов») заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.

    Что это. Сейчас в панели ровно один субъект: requireAuth — булев гейт, а не роль (web.go:182), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть (handlers_monitor.go:49). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, /reload, полный бэкап (это весь /data вместе с sasldb2, то есть все домены сразу), очередь и хвост mail.log — они серверные и к домену не привязаны.

    Почему 2.x, а не v1.x. ТЗ 3 относит «несколько пользователей панели, роли» к явным не-целям (панель рассчитана на одного администратора), поэтому появление второго субъекта — расширение границ проекта, как и Фаза O1: сначала согласование (ТЗ 12.6) и правка ТЗ, только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас {id}/{aid} не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.


Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0)

Эти фазы не входят в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к релизной линии 2.x.x и добавлены в дорожную карту как согласуемые расширения. Реализация — только после явного согласования (ТЗ 12.6): ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.

Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин

Цель: возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), как выключаемый по умолчанию модуль, не затрагивающий поведение и поверхность атаки базового исходящего релея.

Зачем это нужно (сценарии):

  • Backup-MX — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
  • Фронт для сервера без внешнего IP — у оператора есть свой почтовый сервер, который по каким-то причинам сам не может принимать почту из интернета (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.

Граница объёма (критично — что это НЕ):

  • ЭТО: приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (relay_domains + transport_maps + relay_recipient_maps). Postfix здесь — чистый пересыльщик, без локальной доставки.
  • ЭТО НЕ (остаётся out of scope, ТЗ 3): локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также не реализует и не тянет в свой образ движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и не перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.

Почему как опция/плагин:

  • Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию выключено флагом env INBOUND_RELAY_ENABLE=false; включение — осознанный шаг оператора.
  • Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.

Что делать:

  • Env-флаг INBOUND_RELAY_ENABLE (default false); при true — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (postfix-config.sh).
  • master.cf: входной smtp inet на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 не предлагается SASL и не разрешается отправка наружу — только приём для relay_domains.
  • Анти-open-relay для входящей (обязательно): smtpd_relay_restrictions/smtpd_recipient_restrictions входного smtpd принимают почту только для доменов из relay_domains и только для известных получателей (relay_recipient_maps); всё прочее — reject_unauth_destination/reject_unlisted_recipient. Открытый релей и приём «для кого угодно» невозможны.
  • Backscatter: предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
  • Панель управляет: список входящих доменов; для каждого — upstream destination (host:port, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как sender_login_maps в Фазе 4), os/exec без shell (ТЗ 7.6.24).
  • Милтеры: OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
  • Rate-limit/размер: грубый лимит по client IP (anvil, как L1) и message_size_limit на входном smtpd.

Антиспам (важная, но опциональная возможность). Это ценная опция, но она не обязательна: часть операторов вполне устроит слепая пересылка без фильтрации — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию выключен (пустой INBOUND_ANTISPAM_MILTER), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес SelfPost, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost; поэтому тем, кому фильтрация нужна, она должна быть подключаема именно здесь, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:

  • Движок антиспама — отдельный опциональный контейнер (rspamd и т.п.), который оператор запускает только если нужна эта опция (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его не содержит и не запускает — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам).
  • SelfPost предоставляет точку подключения: milter-хук на входном smtpd. Адрес движка задаётся env (например, INBOUND_ANTISPAM_MILTER=inet:antispam:11332, пусто → хук выключен) и добавляется в smtpd_milters только входного тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. milter_default_action для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
  • Нативный backstop без зависимостей: на том же входном хопе доступны средства Postfix по origin IP — reject_rbl_client (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/Received там, где часть фильтрации всё же остаётся на backend.
  • docker-compose: задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
  • Персистентность: новые таблицы и map-файлы под /data — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
  • DNS-документация: для входящего домена нужна MX-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.

Безопасность (ТЗ 7.6 распространяется полностью): валидация ввода на сервере, экранирование записи в конфиги, exec без интерполяции, никакого open relay, защита от backscatter.

Готово, когда: при INBOUND_RELAY_ENABLE=true и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном INBOUND_ANTISPAM_MILTER входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при INBOUND_RELAY_ENABLE=false — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; build/vet/test/образ зелёные.

Риски: open relay/backscatter (снимается relay_domains + relay_recipient_maps + reject_unauth_destination); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). Модель: Opus (инфра/безопасность, риск open relay). Внешняя зависимость деплоя: опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.

Зависимости: не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования.