Files
selfpost/docs/implementation-plan.md
T
mix e8b558eb3b docs: open-questions backlog for v1.0 (attention & discussion)
Capture conscious tradeoffs and hardening candidates that go beyond the
mandatory 7.6 requirements: reverse-proxy rate-limit keying, missing
security response headers, CSRF/SameSite stance, __Host- cookie prefix,
session/ops notes, and the gap that CI does not run go test. None are
compliance defects; each is a decide-later item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:49:15 +03:00

42 KiB
Raw Blame History

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

Статус: черновик для согласования (см. ТЗ, раздел 12, п. 7 — план утверждается до начала кодирования). Основа: specification.md v1.0.

Принцип движения (ТЗ 12.4): сначала минимальный работающий скелет (образ собирается, три процесса стартуют, пустая панель с логином), затем итеративное наращивание. Требования безопасности раздела 7.6 закладываются сразу в соответствующих эндпоинтах, а не «потом» (ТЗ 12.5). Коммиты — только по явной команде (ТЗ 12.1).


Зафиксированные технические решения (до кодирования)

Эти выборы предлагается утвердить вместе с планом — они влияют на всю структуру.

Решение Выбор Обоснование
Module path codeberg.org/mix/selfpost основной репозиторий — Codeberg (ТЗ 11.7)
Базовый образ debian:bookworm-slim зафиксировано ТЗ 4
SQLite-драйвер modernc.org/sqlite (чистый Go, BSD-3) сохраняет статический бинарник без cgo; лицензия permissive (ТЗ 7.1, 12.8)
bcrypt golang.org/x/crypto/bcrypt (BSD) хэш пароля админа (ТЗ 7.6.1)
milter github.com/emersion/go-milter (MIT) единственный жизнеспособный вариант; требует ранней проверки совместимости (ТЗ 7.3, риск)
Front-end html/template + вендоренный HTMX, без npm/SPA зафиксировано ТЗ 7.1
Версия в бинарнике -ldflags "-X main.version=..." для проверки совместимости бэкапа (ТЗ 7.5.А, 11.1)

Каждая внешняя зависимость обоснована и permissive/GPL-совместима (ТЗ 12.8). Больше сторонних зависимостей без согласования не вводим.

Предлагаемая структура репозитория:

cmd/panel/            — main панели (HTTP + milter + log-tailer + rate-limit)
cmd/selfpost-backup/  — CLI-утилита бэкапа (ТЗ 11.6)
internal/store/       — SQLite: схема, миграции, запросы
internal/domain/      — доменная модель, DKIM, OpenDKIM KeyTable/SigningTable
internal/app/         — приложения, SASL (sasldb2), sender_login_maps
internal/postfix/     — генерация конфигов, reload, escaping
internal/milter/      — journal-milter + rate-limit level 2
internal/logtail/     — хвост mail.log, обновление статусов
internal/web/         — хендлеры, сессии, setup-link, middleware
internal/backup/      — полный бэкап/restore, экспорт/импорт домена
web/templates/        — html/template
web/static/           — вендоренный htmx.min.js
build/                — Dockerfile, supervisord.conf, стартовые скрипты, шаблоны конфигов, logrotate
deploy/               — docker-compose.yml (Apache) + фрагменты nginx/Caddy/Traefik

Тестовый стенд: ssh root@selfpost.example.com (PTR настроен, порт 25 предполагается открытым) — dev/env.txt. Секреты разработки — только в dev/.gitignore).


Фаза 0 — Каркас проекта и де-риск milter'а

Цель: структура готова, критический риск проверен до того, как в него вложено много кода.

  • go mod init, структура каталогов, пустые пакеты, main.version.
  • LICENSE — полный текст AGPL-3.0 (ТЗ 11.9).
  • Скелет README.md, Makefile/скрипты сборки с ldflags-версией.
  • Спайк по milter (де-риск, ТЗ 7.3): на тестовом сервере поднять голый Postfix из Debian bookworm + минимальный go-milter, убедиться, что версия протокола совместима и что milter читает From/To/Subject/SASL-login на этапе EOH. Проверить поведение milter_default_action при падении/таймауте milter'а (fail-open). Результат спайка — краткая заметка в dev/.

Готово, когда: go build/go vet проходят на пустом каркасе; спайк подтвердил работоспособность go-milter с Postfix из образа (или зафиксировал альтернативу).


Фаза 1 — Образ + supervisord + три процесса (минимальный скелет)

Цель: docker build собирает образ, контейнер стартует, три процесса живы, пустая панель отвечает на :8080.

  • Dockerfile (bookworm-slim): postfix, opendkim, cyrus-sasl (sasldb2, sasl2-bin), supervisor, logrotate, ca-certificates; сборка Go-бинарников многостадийно.
  • supervisord.conf с priority=: OpenDKIM → panel → (обёртка) Postfix; при неперезапускаемом падении любого процесса контейнер завершается (ТЗ 4).
  • Стартовая обёртка Postfix: опрос готовности milter-сокетов OpenDKIM и journal-milter (test -S, интервал, таймаут ~30с), запуск postfix start-fg только после готовности обоих; при таймауте — выход с ошибкой (ТЗ 4).
  • Панель: HTTP :8080 с заглушкой, stub-listener journal-milter (создаёт сокет, чтобы обёртка проходила), stub log-tailer.
  • Непривилегированный пользователь для панели (ТЗ 7.6.8).

Готово, когда: образ собирается, docker run даёт три живых процесса, панель отдаёт заглушку, обёртка корректно ждёт сокеты.


Фаза 2 — Персистентность, инициализация админа, вход

Цель: безопасный вход в панель одного администратора.

  • SQLite-схема + миграции: домены, приложения, админ, send_log, лимиты, настройки, флаг «настройка завершена»/setup-токен (ТЗ 9). Единый корень /data (ТЗ 7.5.А, 9).
  • Setup secret-link (ТЗ 7.6.1): токен ≥128 бит из crypto/rand; вывод в stdout + /data/setup-token; TTL 10 мин с перегенерацией; rate-limit маршрута; subtle.ConstantTimeCompare; неудачи НЕ инвалидируют токен; одноразовая форма создания админа; инвалидация навсегда после успеха (маршрут → 404).
  • bcrypt-хэш пароля админа; никакого plaintext.
  • Логин, сессии (крипто-случайный токен, cookie HttpOnly/Secure/SameSite), rate-limit логина (ТЗ 7.6.56).
  • Базовый layout html/template, вендоренный HTMX, middleware аутентификации.

Готово, когда: первый запуск печатает setup-ссылку, админ создаётся один раз, повторный /setup → 404, вход/выход работают, токены сессий безопасны.


Фаза 3 — Домены + OpenDKIM

Цель: добавление/удаление домена с генерацией DKIM.

  • Добавить/список/удалить домен; при удалении — предупреждение о каскадном удалении приложений (ТЗ 7.2.4).
  • Генерация DKIM-ключа + селектор per-domain (дефолт из DKIM_SELECTOR_DEFAULT); ключи в /data/... переживают рестарт (ТЗ 6, 9).
  • Поддержка KeyTable/SigningTable, reload OpenDKIM.
  • Показ DKIM TXT-записи per-domain (ТЗ 7.2.10).
  • Безопасная запись конфигов с санитизацией/экранированием (ТЗ 7.6.4); os/exec без shell и без интерполяции ввода (ТЗ 7.6.3); строгая валидация имени домена (whitelist символов, ТЗ 7.6.2).

Готово, когда: домен добавляется/удаляется, DKIM-ключ генерируется и переживает рестарт, TXT-запись показывается, OpenDKIM перечитывает таблицы.


Фаза 4 — Приложения + SASL + привязка к домену

Цель: ядро доменной модели (ТЗ 4.1, 5.1).

  • Создание учётки в sasldb2 (эквивалент saslpasswd2), генерация сильного пароля, показ один раз (ТЗ 7.6.1).
  • Режим адресов на уровне приложения: «любой адрес домена» (wildcard @example.com) либо «список» (запись на каждый адрес); серверная валидация принадлежности каждого адреса домену приложения (ТЗ 7.6.2).
  • Поддержка smtpd_sender_login_maps соответственно режиму; many-to-one.
  • Список приложений домена с текущим режимом; редактирование режима; удаление приложения (только его записи); перевыпуск пароля (режим сохраняется) — с postfix reload (ТЗ 7.2.59).

Готово, когда: приложения создаются/редактируются/удаляются, карта привязки корректно пересобирается, пароль показывается один раз.


Фаза 5 — Полная конфигурация Postfix (исходящий релей)

Цель: реальная отправка почты с аутентификацией и DKIM.

  • master.cf: smtps 465 (wrapper TLS, smtpd_tls_wrappermode=yes) как основной; submission 587 (STARTTLS, smtpd_tls_auth_only=yes) — опционально, тем же способом (ТЗ 5, 5.1).
  • SASL (smtpd_sasl_auth_enable), TLS cert/key из TLS_CERT_FILE/TLS_KEY_FILE (read-only mount), периодический postfix reload для обновления сертификата (ТЗ 5.2).
  • smtpd_recipient_restrictions (permit_sasl_authenticated, reject_unauth_destination), smtpd_sender_restrictions + reject_sender_login_mismatch; никакого open relay (ТЗ 5.4, 5.1.3).
  • Исходящая доставка напрямую (MX-lookup, smtp_tls_security_level=may).
  • Уровень 1 rate-limit (anvil: smtpd_client_message_rate_limit, anvil_rate_time_unit) из env (ТЗ 5.5, 7.4).
  • Milter-цепочка smtpd_milters: OpenDKIM + journal-milter; milter_default_action для journal — fail-open (ТЗ 7.3).

Готово, когда: с тестового сервера письмо реально уходит получателю, подписано DKIM, аутентификация обязательна, привязка отправителя работает, неаутентифицированный релей отклоняется.


Фаза 6 — Journal-milter и обновление статусов (Send Log, бэкенд)

Цель: структурированный журнал отправки. Самый чувствительный узел (ТЗ 7.3) — повышенное внимание к тестам.

  • Реализация journal-milter на go-milter: на EOH читает домен, SASL-login, From, To, Subject; создаёт запись send_log со статусом «в очереди», привязка к queue-id.
  • Отдельная запись на пару (queue-id, получатель) (ТЗ 7.3.3).
  • Log-tailer: горутина хвостит mail.log, парсит sent/bounced/deferred по queue-id, обновляет записи.
  • Retention: фоновая очистка старше SEND_LOG_RETENTION_DAYS (дефолт 90).
  • Тесты отказа: поведение при падении/таймауте milter'а — приём почты не блокируется (fail-open проверяется явно).

Готово, когда: отправленное письмо порождает запись, статус доходит до финального, ретеншн чистит старое; убийство milter'а не роняет приём почты.


Фаза 7 — UI мониторинга (журнал, очередь, лог)

Цель: экраны наблюдения с автообновлением.

  • Журнал отправки: таблица (время, домен, приложение, From, To, Subject, статус), серверные фильтры по домену и приложению (WHERE), пагинация, HTMX-polling; экранирование вывода (ТЗ 7.3, 7.6.7).
  • Просмотр очереди (postqueue -p в читаемом виде), HTMX-polling (ТЗ 7.2.11).
  • Хвост mail.log, HTMX-polling (ТЗ 7.2.13).
  • Кнопка ручного reload (ТЗ 7.2.12).
  • Fragment-эндпоинты возвращают HTML-куски, не JSON (ТЗ 7.1).

Готово, когда: три экрана работают, фильтры серверные, автообновление через polling, вывод экранирован.


Фаза 8 — Дифференцированные лимиты (rate limit уровень 2)

Цель: лимиты на домен/приложение поверх backstop уровня 1.

  • Настройка в панели: ожидаемые IP + лимит (N писем за окно) на уровне домена и приложения; оба необязательны; пустая IP-привязка допустима (ТЗ 7.4).
  • Проверка в milter: ключ — client IP; счётчик из SQLite (переиспользует данные журнала); превышение → отказ 4xx + опциональная запись «отклонено по лимиту»; fail-open при недоступности milter'а (ТЗ 7.4).

Готово, когда: лимит на домен/приложение срабатывает (4xx), пустая привязка не ломает отправку, при падении milter'а остаётся уровень 1.


Фаза 9 — Бэкап/восстановление и экспорт/импорт домена

Цель: переезд «прост как архив» + перенос одного домена.

  • Полный бэкап (ТЗ 7.5.А): архив всего /data (SQLite через VACUUM INTO/Backup API для консистентного снимка на живом контейнере, DKIM-ключи, sasldb2, manifest.json с версией). Без TLS-сертификатов и очереди Postfix.
  • Кнопка в панели + CLI selfpost-backup через docker exec (ТЗ 11.6).
  • Restore: сверка версии манифеста с версией бинарника; при несовпадении — отказ с понятным сообщением (какой тег образа нужен); состояние регенерируется из SQLite тем же путём, что при обычном старте, без отдельной ветки restore (ТЗ 7.5.А).
  • Экспорт/импорт домена (ТЗ 7.5.Б): файл с именем домена, DKIM-ключом/селектором, приложениями и их записями sasldb2 (рабочие пароли без перевыпуска); импорт восстанавливает домен без смены DNS. Файл помечается как секрет.

Готово, когда: бэкап скачивается (панель и CLI), restore на чистом контейнере той же версии поднимает всё без пере-настройки; несовпадение версии отклоняется; экспорт/импорт домена переносит рабочие креды.


Фаза 10 — Деплой и документация

Цель: поставка и эксплуатационная документация.

  • docker-compose.yml: Apache как основной reverse-proxy «из коробки», фиксированный тег версии (не :latest), bind mount ./data:/data + read-only mount сертификатов, hardening (non-root, cap_drop, ограничение ФС) (ТЗ 10, 9).
  • Альтернативные фрагменты: nginx, Caddy (проверить актуальный путь хранилища), Traefik (шаг извлечения PEM) (ТЗ 10.3).
  • logrotate для mail.log в образе (ежедневно, 7–14 файлов, ТЗ 9).
  • README.md: требования к площадке (чеклист), настройка DNS per-domain (SPF/DKIM/DMARC + PTR), прогрев IP, процедуры бэкапа/restore и экспорта/импорта (оба файла — секреты), фиксированный тег, требования к машине, лицензия AGPL-3.0 и её смысл, ссылки Codeberg (основной)/GitHub (зеркало) (ТЗ 10, 11.7).

Готово, когда: docker compose up за Apache поднимает рабочий стенд; README покрывает установку, DNS, бэкап/миграцию, лицензию.


Фаза 11 — Финальный проход по безопасности и приёмка

Цель: соответствие разделу 7.6 и критериям ТЗ 12.

  • Аудит: панель не под root и права ФС; серверная валидация ввода; os/exec без shell; экранирование всего вывода из логов/очереди; санитизация записи в конфиги; флаги cookie; rate-limits (setup, логин).
  • go build, go vet, go test; docker build; проверка старта контейнера (ТЗ 12.2–3).
  • Опционально: /security-review по диффу ветки.

Готово, когда: все пункты 7.6 подтверждены, сборка/вет/тесты/образ зелёные, контейнер стартует чисто.


Открытые вопросы — требует внимания и обсуждения (перед фиксацией 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). За дефолтным Apache это адрес прокси → лимитеры логина и /setup фактически глобальны. Осознанный выбор: не парсить X-Forwarded-For (иначе тривиально обходится подделкой заголовка). По ТЗ 7.6.1 реальная защита setup — 128-бит энтропии токена, rate-limit там defense-in-depth. Побочный эффект: атакующий может исчерпать общий bucket логина (10 попыток/15 мин) и на 15 минут заблокировать вход легитимному админу (lockout-DoS). Варианты: (а) оставить как есть (просто, по ТЗ достаточно); (б) парсить XFF только от доверенного прокси (TRUSTED_PROXY_CIDR) → настоящий per-client лимит; (в) вместо жёсткой блокировки — экспоненциальная задержка ответа, чтобы brute-force тормозился, но легитимный вход не блокировался.
  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 и задокументировать в deploy/? Рекомендация — минимальный набор из панели (HSTS/nosniff/frame-ancestors 'none'/строгий CSP default-src 'self'), т.к. панель знает свою модель контента, а прокси у всех разный.
  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) — альтернатива. Оставить как есть (по ТЗ), но отметить в README, что для сред с централизованными логами предпочтителен файл.
  6. Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Вопрос: нужен ли эндпоинт «сменить пароль администратора» в v1.x (разумное мелкое добавление) — 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 ./... + gofmt -l), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу.
  2. Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в progress.md, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.

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

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

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


Ключевые риски и как их снимаем

  1. Go-milter (ТЗ 7.3) — самый рискованный узел (баг ломает не только журнал, а сам релей). Снимается ранним спайком (Фаза 0) и явными тестами отказа (Фаза 6), fail-open через milter_default_action.
  2. Холодный старт milter-сокетов — обёртка Postfix ждёт готовности сокетов (Фаза 1).
  3. Версионирование бэкапа — фиксированный тег образа + проверка манифеста (Фазы 9–10).
  4. Открытый релей / утечка кред — привязка sender_login_maps + reject_sender_login_mismatch, обязательный SASL, отсутствие mynetworks-авторизации (Фаза 5).

Порядок и зависимости

Фазы линейны по зависимостям (0→1→2→3→4→5→6→7→8→9→10→11), кроме спайка milter (0), который де-рискует Фазу 6 заранее. После каждой фазы — работающий инкремент, пригодный для проверки на тестовом сервере.

Опциональная Фаза O1 (входящий релей) — вне этого линейного базиса: не является частью v1.0, зависит только от готового исходящего тракта (Фазы 1–5) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования.