Files
selfpost/docs/implementation-plan.md
T
mix 158b5323d3 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

295 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План реализации: SelfPost
**Статус:** черновик для согласования (см. ТЗ, раздел 12, п. 7 — план утверждается до начала кодирования).
**Основа:** [specification.md](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.mixfed.ru` (PTR настроен, порт 25 предполагается открытым) — [dev/env.txt](../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](../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. Надёжность и эксплуатация
7. **Сессии только в памяти** — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
8. **Окно потери строк мониторингового лога при ротации** (`copytruncate`, Фаза 10) — несколько строк `mail.log` могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство.
9. **Поведение при незаданном `SELFPOST_HOSTNAME`** — realm SASL и хост setup-ссылки падают в `localhost`. Для реального деплоя hostname обязателен. **Вопрос:** делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.
### C. CI и тесты
10. **CI не гоняет `go test`.** [.github/workflows/release.yml](../.github/workflows/release.yml) на теге только собирает и пушит образ; `go vet` выполняется внутри Dockerfile-сборки, но **юнит-тесты в CI не запускаются** — вся тестовая проверка идёт вручную на dev-сервере. **Рекомендация:** добавить обычный workflow на push/PR (`go vet` + `go test ./...` + `gofmt -l`), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу.
11. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в progress.md, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.
### D. Указатель на объём 2.x
12. **Входящий релей и 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) до кодирования.