Files
selfpost/docs/roadmap.md
T
mix ecc4184324 docs: schedule implementation-plan.md retirement in roadmap
The document is closed: no unique content remains — § D (pre-release
security review) is duplicated in progress.md, security.md and the
CHANGELOG, and B.1-B.3/C.4 were trimmed in 60addd5. It stays until the
tag only because it describes the release gate.

Record the retirement as a v1.x tail item in roadmap.md with the
concrete steps: archive the file and retarget its references, including
the stale "plan C.4" pointers in Makefile, release.yml and the e2e test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:36:51 +03:00

20 KiB
Raw Blame History

Дорожная карта: SelfPost 2.x.x

Статус: здесь собран объём, отнесённый к релизной линии 2.x.x — вне базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация — только после явного согласования (product.md, development.md § Agent rules): product.md явно исключает часть этого объёма (приём входящей почты; несколько пользователей/роли), поэтому включение — сознательное расширение границ проекта, а не доработка по своей инициативе. Присутствие пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным решением.

Основа: product.md v1.0. Несделанное для v1.0/v1.x — в implementation-plan.md. Хвост закрытого документационного прохода (D1–D9) — в секции ниже.


v1.x — хвост документации и деплоя

Статус: не блокирует релизный тег; перенесено из закрытого documentation-plan.md (бывшая находка 11 и отложенный пункт D4). Делать по желанию или в релизном коммите, где указано.

Quick start на Codeberg. README.md Quick start тянет docker-compose.yml и .env.example с raw.githubusercontent.com (зеркало). Основной репозиторий — Codeberg; заменить URL на актуальные raw-ссылки Codeberg (codeberg.org/mix/selfpost/raw/branch/main/deploy/...).

Тег образа в compose. В deploy/docker-compose.yml поле image: бампить до версии релиза в том же коммите, что и git-тег vX.Y.Z — не раньше. Сейчас может отставать от целевой версии релиза; несовпадение мешает только до первого выката по тегу.

Каталог docs/logo. Пустой; либо наполнить (если нужен отдельный asset для внешних ссылок), либо удалить каталог, чтобы не создавать ложное ожидание.

Убрать implementation-plan.md — в релизном коммите. Документ закрыт: уникального содержания в нём нет, § D (предрелизная ревизия безопасности) продублирован в progress.md, security.md и CHANGELOG [Unreleased]/Security, а разделы B.1–B.3 и C.4 вырезаны ещё в 22f86d1. Держится до тега только потому, что описывает релизный гейт, пока тот формально не закрыт. При резке версии:

  1. Переместить в docs/archive/ (рядом со specification-v1.0.md) — история § D сохраняется, из активной документации уходит.
  2. Перецелить ссылки из кода и CI (Makefile, .github/workflows/release.yml, test/e2e/main_test.go) — они ссылаются на «план C.4», секцию, которой в файле уже нет; актуальное описание e2e — в development.md.
  3. Перецелить ссылки из документации: README.md («Open v1.x questions» — открытых вопросов там нет) → progress.md; code-review.md, security.md, documentation-plan.md, progress.md и шапку этого файла → на progress.md/security.md.
  4. В progress.md убрать шаг «Открыть implementation-plan.md» — он выполнен.

Готово, когда: Quick start указывает на Codeberg; тег образа в compose совпадает с релизом; docs/logo либо содержит файлы, либо отсутствует; implementation-plan.md в docs/archive/, ссылок на него в активных документах и в коде/CI не осталось.

Send-log vs mail.log (опционально). После рестарта панели или пересоздания контейнера строки send-log могут навсегда остаться queued — log-tailer не дочитывает пропущенный хвост, mail.log не в /data. Rename-ротация (B.2) это не лечит. Кандидаты, если станет больно: persist позиции чтения, volume для лога, сверка зависших строк через postqueue. As-built и принятый риск: architecture.md § Log tailer, security.md.


Фаза 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, product.md): локальная доставка в почтовые ящики, 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 (security.md).
  • Милтеры: 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 его не содержит и не запускает — образ и принцип «один контейнер, три процесса» неизменны, product.md out of scope не нарушается (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.

Безопасность (security.md): валидация ввода на сервере, экранирование записи в конфиги, 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, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (development.md) до кодирования.


Роль администратора домена — кандидат на 2.x

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

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

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