Files
selfpost/docs/implementation-plan.md
T
mix 147072dbb9 panel: shared nav, account settings, backup page, connection settings
Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.

New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.

Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.

The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.

Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.

Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:34:59 +03:00

42 KiB
Raw Blame History

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

Статус: базовый линейный план (фазы 0→11, v1.0) выполнен и принят — см. progress.md (текущее состояние) и CHANGELOG.md (история релизов). Он здесь не повторяется.

Ниже остаётся только то, что ещё не сделано: открытые вопросы для согласования и опциональная линия 2.x.x.

Фаза 12 (UI/UX: общий nav-partial, /account, /backup, параметры подключения, кнопки Copy, скрытие поля адресов) выполнена — детали в CHANGELOG.md, здесь не повторяется.

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


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

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

A. Безопасность — hardening сверх обязательного 7.6

  1. Rate-limit за обратным прокси кеился по RemoteAddr (internal/web/web.go clientIP). Решено и реализовано: вариант (б) — парсить X-Forwarded-For, но только когда прямой peer (RemoteAddr) входит в TRUSTED_PROXY_CIDR (список CIDR через запятую, env, по умолчанию пусто); тогда используется последний элемент XFF (адрес, добавленный самим доверенным прокси). Без настройки TRUSTED_PROXY_CIDR поведение не меняется (лимит по RemoteAddr, глобальный за прокси). См. deploy/.env.example.
  2. Нет security-заголовков ответа — панель не шлёт Strict-Transport-Security, Content-Security-Policy, X-Frame-Options/frame-ancestors, X-Content-Type-Options: nosniff, Referrer-Policy. XSS уже закрыт автоэкранированием html/template (7.6.7), CSRF — SameSite=Lax, но заголовки — дешёвый второй эшелон (clickjacking, downgrade, sniffing). Решено: эмитить из панели (единый мидлварь, ~10 строк), не перекладывать на reverse-proxy. Общий принцип: всю сложность стараемся держать в сервисе, а конфигурация reverse-proxy должна оставаться максимально простой, чтобы её было сложно сломать неудачной правкой. Реализация — см. Фазу 14.A.
  3. CSRF — только SameSite=Lax, без токенов. Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». Вопрос: считать SameSite=Lax достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен.
  4. Cookie без префикса __Host-. Сейчас selfpost_session (Secure/HttpOnly/SameSite=Lax/Path=/). Префикс __Host- дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. Вопрос: переименовать (учесть dev-режим PANEL_COOKIE_SECURE=false__Host- требует Secure, т.е. только когда secure включён).
  5. Setup-ссылка печатается в stdout контейнера. По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл /data/setup-token (0600) — альтернатива, уже реализована в коде (internal/web/setup.go announce/token file). Решено: базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл /data/setup-token как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. Реализация — см. Фазу 14.B.
  6. Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Решено и реализовано (Фаза 12): добавлен раздел настроек аккаунта /account (логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 2FA и мульти-админ остаются явно 2.x/вне объёма.

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

  1. Сессии только в памяти — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
  2. Окно потери строк мониторингового лога при ротации (copytruncate, Фаза 10) — несколько строк mail.log могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство.
  3. Поведение при незаданном SELFPOST_HOSTNAME — realm SASL и хост setup-ссылки падают в localhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.

C. CI и тесты

  1. CI не гоняет go test. .github/workflows/release.yml на теге только собирает и пушит образ; go vet выполняется внутри Dockerfile-сборки, но юнит-тесты в CI не запускались — вся тестовая проверка шла вручную на dev-сервере. Реализовано: добавлен обычный workflow на push/PR (go vet + go test ./...).
  2. Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.

D. Указатель на объём 2.x

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

Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов

Статус: запланирована, не начата.

Цель: дать оператору один взгляд на «сервис жив и почта не будет зарубаться из-за DNS» — два независимых экрана: здоровье процесса (/status) и корректность DNS для каждого отправляющего домена (встроено в существующую страницу домена). Выходит за рамки ТЗ v1.0 (не было в разделе 7.2), согласовано с пользователем как отдельная фаза. /status также становится стартовой страницей панели (см. пункт C) — первое, что видит админ после логина, вместо списка доменов — и получает кнопку «Reload», перенесённую с дашборда с понятным пояснением назначения (см. пункт D).

A. /status — здоровье сервера (только сервер, без доменов)

  1. Процессы supervisordsupervisorctl status (fixed-argv, без shell, как остальные exec-вызовы проекта), статус opendkim/postfix/panel/cert-reload/logrotate.
  2. Очередь Postfix — переиспользовать internal/postfix.Queue().
  3. TLS-сертификат — распарсить x509.NotAfter из TLS_CERT_FILE, предупреждение при приближении срока истечения.
  4. Milter-сокеты (os.Stat на opendkim.sock/journal.sock): отсутствие opendkim.sockошибка (при default_action=tempfail почта не уходит, см. progress.md:132); отсутствие journal.sockпредупреждение (fail-open, почта уходит, но Send Log не пишется).
  5. PTR / прямое-обратное соответствие (FCrDNS) — ключевая проверка для доставляемости:
    • резолвим A/AAAA SELFPOST_HOSTNAME → IP сервера;
    • резолвим PTR этого IP → имя;
    • сравниваем PTR-имя с SELFPOST_HOSTNAME.
    • OK — совпадают; ошибка — PTR отсутствует или не совпадает (многие принимающие сервера отклоняют/спамят почту без корректного PTR).
    • Резолвленный IP сервера переиспользуется в пункте B.2 (SPF) — отдельный env для IP не нужен.

Дешёвые проверки (1–4) — HTMX-polling как на остальных экранах мониторинга (~5с). PTR/hostname-lookup чуть дороже сети — кэш с TTL (например 1 мин) или отдельная кнопка «Recheck», не завязывать на 5-секундный polling.

B. DNS-статус домена (на странице domain_detail.html, рядом с существующей DKIM TXT-записью)

  1. DKIM — резолвим <selector>._domainkey.<domain> TXT, сравниваем с реальным ключом OpenDKIM (переиспользовать логику генерации записи из dkim.go).
  2. SPF (поверхностная проверка, по решению пользователя) — резолвим TXT домена, ищем запись с v=spf1. Если найдена — проверяем только присутствие механизма, покрывающего IP сервера (ip4:/ip6: буквально, либо a/mx без аргумента, резолвящийся в IP сервера из A.5). Без рекурсии по include, без полной RFC 7208-оценки pass/fail/softfail — осознанное упрощение (без новых зависимостей).
  3. DMARC — TXT _dmarc.<domain>, наличие + политика (none/quarantine/reject).
  4. Статусы по каждому пункту: не найдено / найдено, но не покрывает наш сервис / корректно. Кэш на домен (TTL ~5–10 мин) + кнопка «Recheck» (без autopolling — DNS дороже, чем локальные проверки блока A).

C. /status — стартовая страница панели

Сейчас корневой маршрут GET /{$} (handleDashboard, web.go:109) отдаёт список доменов — это же первая страница после логина. По решению пользователя стартовой страницей должна быть страница статуса, а не список доменов.

  • Список доменов (карточки «Add a sending domain» + «Domains» из dashboard.html) переезжает на новый маршрут GET /domains (тот же handleDashboard, просто перевешенный на другой путь; POST /domains для добавления домена остаётся как есть — коллизий с GET /domains нет, разные методы).
  • Корневой GET /{$} начинает рендерить /status (страницу из блока A) — либо редиректом /{$}/status, либо status-хендлер напрямую вешается и на /, и на /status (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант.
  • Логин: handleLogin после успешной аутентификации сейчас редиректит на / — поведение не меняется (просто / теперь означает статус, а не домены), правки в handlers_auth.go не требуется.
  • Навигация: пункт «Status» становится первым в общем nav-partial (layout.html, Фаза 12) и получает Active == "status"; пункт «Domains» указывает на /domains вместо /. Это расширяет список Active-значений, заведённый в Фазе 12, а не меняет его архитектуру.
  • Везде, где по коду сейчас зашит редирект/ссылка на / как «страница доменов» (например, <a class="back" href="/">← Domains</a> в queue.html, sendlog.html, logtail.html, domain_detail.html, domain_delete.html), ссылку нужно поменять на /domains.

Готово (доп. к критерию блока A/B): GET / открывает /status; список доменов доступен по GET /domains; все ссылки «← Domains» и пункт навигации «Domains» ведут на /domains; логин после успешной аутентификации попадает на страницу статуса.

Риски: нужно пройтись по всем захардкоженным href="/" в шаблонах (см. список выше) — пропущенная ссылка тихо ведёт на статус вместо доменов, а не ломается явно, поэтому стоит грепнуть href="/" после реализации и проверить каждое совпадение.

D. Кнопка «Reload» — назначение непонятно, присутствует непоследовательно

handleReload (handlers_domains.go:119) пересобирает конфигурацию OpenDKIM и Postfix-карту отправителей из БД и перечитывает оба демона (spec 7.2.12) — это ручной drift-recovery («на всякий случай пересобери конфиг с нуля»), а не элемент навигации. Сейчас кнопка:

  • есть только на дашборде (dashboard.html:9), в топбаре вперемешку с навигационными ссылками — на queue/sendlog/logtail/domain_detail её нет;
  • подписана просто «Reload» без единого слова, что она делает;
  • после нажатия даёт флеш «Configuration reloaded.» — тоже без объяснения, что было пересобрано и зачем это могло понадобиться.

Итог: пользователю (да и админу, впервые видящему панель) неясно, что это за действие, когда его стоит нажимать, и почему оно недоступно с других страниц.

Решение: перенести Reload на /status (блок A) — тематически это ровно то же самое действие, что и остальной блок здоровья сервера (проверка/восстановление рабочего состояния демонов), и после Фазы 13 /status и так становится стартовой страницей, так что кнопка не теряется, а оказывается на самом заметном месте.

  • Убрать кнопку/форму POST /reload из dashboard.html и топбара.
  • Добавить на /status карточку «Configuration» (или секцию рядом с блоком A.1–A.4) с формой POST /reload и явным пояснением: «Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use this if you edited files manually, restored a backup, or the config looks out of sync with the domain/application list below — it does not affect the mail queue or TLS.»
  • Редирект handleReload — сейчас "/?reloaded=1" (handlers_domains.go:130); после переноса меняется на "/status?reloaded=1", и флеш-сообщение обрабатывается на странице статуса (dashboardFlash для reloaded убирается — переносится в статус-хендлер, deleted/остальные флеши дашборда остаются на месте).
  • Логика handleReload (сам Resync обоих компонентов) не меняется — это чисто перенос UI и текста, не изменение поведения.

Готово (доп. к критерию блока A/B/C): кнопка Reload есть только на /status, с текстом, объясняющим, что именно она пересобирает и когда это нужно; на дашборде/остальных страницах кнопки/флеша про reload больше нет.

Риски: нет — чисто перенос существующего, уже проверенного действия; главное — не потерять флеш-сообщение при переносе редиректа.

Архитектура

  • Новый пакет internal/dnscheck: ServerIP(hostname) (A/AAAA lookup), PTRCheck(hostname, ip), DKIMCheck(domain, selector, expectedKey), SPFCheck(domain, serverIP), DMARCCheck(domain) — все с таймаутом на DNS-запрос (~5с), через net.DefaultResolver/контекст.
  • Кэш результатов в памяти (мьютекс, TTL), без изменений схемы БД/миграций.
  • Веб: GET /status + GET /status/fragment (быстрый блок A, для polling); секция DNS-статуса на domain_detail.html + POST /domains/{id}/dns-recheck (форс, обход кэша).
  • Безопасность: ничего нового по ТЗ 7.6 не добавляет — DNS/exec-вызовы только за авторизованным админом, без пользовательского ввода в exec (доменные имена уже провалидированы при добавлении домена), таймауты на все сетевые вызовы (защита от зависания страницы).

Готово, когда: /status показывает живой статус всех пяти пунктов блока A и обновляется polling'ом и является стартовой страницей (GET /); список доменов доступен на GET /domains; на странице домена отображается статус DKIM/SPF/DMARC с кнопкой Recheck; PTR-проверка корректно ловит и совпадение, и несовпадение (проверено на реальном домене selfpost.mixfed.ru, у которого PTR уже настроен, — см. [selfpost-prod-deployment.md]); кнопка «Reload» присутствует только на /status, с пояснением назначения; gofmt/vet/test/docker build зелёные.

Риски: DNS-резолверы могут быть медленными/недоступными — все проверки таймаутят и не блокируют остальной UI (страница рендерится с "неизвестно/timeout", а не висит). Ложные срабатывания SPF-эвристики (например, домен покрывает IP сервера через include: стороннего сервиса, который в свою очередь резолвится в IP сервера) — задокументировать как известное ограничение поверхностной проверки. Плюс риск блока C — не пропустить захардкоженную ссылку на /.

Модель: Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).

Зависимости: нет — общий nav-partial и Active-механизм навигации уже введены Фазой 12; использует также уже реализованные internal/postfix.Queue(), DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.


Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)

Статус: запланирована, не начата.

Цель: довести до кода два уже принятых, но пока не реализованных решения из раздела A. Безопасность (остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6).

A. Security-заголовки ответа (пункт A.2)

Добавить в панель единый middleware, оборачивающий все ответы (кроме, возможно, уже застриманных HTMX-фрагментов, где это не мешает), выставляющий:

  • Strict-Transport-Security (только когда PANEL_COOKIE_SECURE/TLS включён — по аналогии с __Host-/Secure-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден);
  • X-Content-Type-Options: nosniff;
  • X-Frame-Options: DENY (или Content-Security-Policy: frame-ancestors 'none' — эквивалент, дублировать не обязательно);
  • Referrer-Policy: same-origin (или no-referrer — выбрать более строгий, поведение панели не зависит от referrer);
  • Content-Security-Policy — минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами (internal/web/templates) на предмет <script>/style="..."/onclick перед тем, как писать политику, чтобы не сломать текущий UI.

Готово, когда: все ответы панели содержат перечисленные заголовки (кроме HSTS в dev/non-secure режиме); UI (включая HTMX-фрагменты и polling) продолжает работать без консольных ошибок CSP; gofmt/vet/test зелёные.

Риски: слишком строгий CSP может тихо сломать inline-скрипты/стили в существующих шаблонах — проверить вручную в браузере (открыть каждую страницу, проверить консоль на CSP-violations) после реализации, а не полагаться только на юнит-тесты.

Модель: Sonnet (небольшой, хорошо специфицированный мидлварь).

B. Документация про /data/setup-token (пункт A.5)

Код уже пишет setup-токен в /data/setup-token (0600) в дополнение к stdout (internal/web/setup.go) — это не требует изменений. Остаётся только документационная задача:

  • В README (раздел про первый запуск/setup-ссылку) добавить абзац: по умолчанию ссылка печатается в stdout контейнера (spec 7.6.1), но токен также лежит в файле /data/setup-token внутри смонтированного /data — для тех, у кого логи контейнера уезжают в центральный агрегатор и не хочется, чтобы токен там оседал на 10 минут, безопаснее прочитать файл (docker exec / примонтированный volume) вместо просмотра логов.

Готово, когда: README содержит этот абзац рядом с описанием setup-ссылки.

Риски: нет — чисто документация, код не меняется.

Модель: Sonnet (документация).

Зависимости: нет, можно делать независимо от Фаз 12/13.


Опциональные фазы — целевой релиз 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) до кодирования.