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>
42 KiB
План реализации: 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.5–6). - Базовый 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.5–9).
Готово, когда: приложения создаются/редактируются/удаляются, карта привязки корректно пересобирается, пароль показывается один раз.
Фаза 5 — Полная конфигурация Postfix (исходящий релей)
Цель: реальная отправка почты с аутентификацией и DKIM.
master.cf:smtps465 (wrapper TLS,smtpd_tls_wrappermode=yes) как основной;submission587 (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
- Rate-limit за обратным прокси кеится по
RemoteAddr(internal/web/web.goclientIP). За дефолтным 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 тормозился, но легитимный вход не блокировался. - Нет 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'/строгий CSPdefault-src 'self'), т.к. панель знает свою модель контента, а прокси у всех разный. - CSRF — только
SameSite=Lax, без токенов. Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». Вопрос: считатьSameSite=Laxдостаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. - Cookie без префикса
__Host-. Сейчасselfpost_session(Secure/HttpOnly/SameSite=Lax/Path=/). Префикс__Host-дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. Вопрос: переименовать (учесть dev-режимPANEL_COOKIE_SECURE=false—__Host-требуетSecure, т.е. только когда secure включён). - Setup-ссылка печатается в stdout контейнера. По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл
/data/setup-token(0600) — альтернатива. Оставить как есть (по ТЗ), но отметить в README, что для сред с централизованными логами предпочтителен файл. - Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Вопрос: нужен ли эндпоинт «сменить пароль администратора» в v1.x (разумное мелкое добавление) — 2FA и мульти-админ явно 2.x/вне объёма.
B. Надёжность и эксплуатация
- Сессии только в памяти — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
- Окно потери строк мониторингового лога при ротации (
copytruncate, Фаза 10) — несколько строкmail.logмогут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство. - Поведение при незаданном
SELFPOST_HOSTNAME— realm SASL и хост setup-ссылки падают вlocalhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.
C. CI и тесты
- CI не гоняет
go test. .github/workflows/release.yml на теге только собирает и пушит образ;go vetвыполняется внутри Dockerfile-сборки, но юнит-тесты в CI не запускаются — вся тестовая проверка идёт вручную на dev-сервере. Рекомендация: добавить обычный workflow на push/PR (go vet+go test ./...+gofmt -l), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу. - Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в progress.md, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.
D. Указатель на объём 2.x
- Входящий релей и 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.2–4). - Милтеры: 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, поднимается оператором при включении опции.
Ключевые риски и как их снимаем
- Go-milter (ТЗ 7.3) — самый рискованный узел (баг ломает не только журнал, а сам релей). Снимается ранним спайком (Фаза 0) и явными тестами отказа (Фаза 6), fail-open через
milter_default_action. - Холодный старт milter-сокетов — обёртка Postfix ждёт готовности сокетов (Фаза 1).
- Версионирование бэкапа — фиксированный тег образа + проверка манифеста (Фазы 9–10).
- Открытый релей / утечка кред — привязка
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) до кодирования.