Files
selfpost/docs/roadmap.md
T
mixeme 1dd0aa9fea docs: align operator and as-built docs with the code
Fix setup URL shape, import encryption UI, architecture layering/routes,
and stale plan/roadmap pointers so the prose matches what the tree does.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:46:03 +03:00

193 lines
26 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 2.x.x
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
только после явного согласования ([product.md](product.md),
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
[product.md](product.md) явно исключает часть этого объёма (приём входящей
почты; несколько пользователей/роли), поэтому включение — сознательное
расширение границ проекта, а не доработка по своей инициативе. Присутствие
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
решением.
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
[development.md](development.md). Несделанное для v1.0/v1.x до тега — в
[implementation-plan.md](implementation-plan.md) и
[v1.x-closure-plan.md](v1.x-closure-plan.md). Хвост закрытого
документационного прохода (D1–D9) — в секции ниже.
---
## v1.x — хвост документации и деплоя
**Статус:** не блокирует релизный тег; бывший хвост закрытого
документационного прохода (D1–D9). Делать по желанию или в релизном коммите,
где указано. Сводка чек-листов до тега —
[v1.x-closure-plan.md](v1.x-closure-plan.md).
**Тег образа в compose + git tag — один релизный коммит (R1).** В
[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить
до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше.
Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает
только до первого выката по тегу. Сам тег — последний шаг релизного гейта:
содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде
оператора ([development.md](development.md) § Commits and release build). После
тега `release.yml` собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`,
поэтому compose с новым тегом и сам тег обязаны появиться вместе — иначе
compose неделю ссылается на несуществующий образ.
**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт:
уникального содержания в нём нет, § D (предрелизная ревизия безопасности)
продублирован в [security.md](security.md) и CHANGELOG `[0.5.0]/Security`,
а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только
потому, что описывает релизный гейт, пока тот формально не закрыт. При резке
версии:
1. Удалить файл (история § D — в git и CHANGELOG; `docs/archive/` не храним).
2. Перецелить ссылки из кода и CI ([Makefile](../Makefile),
[.github/workflows/release.yml](../.github/workflows/release.yml),
[test/e2e/main_test.go](../test/e2e/main_test.go)) — они ссылаются на «план
C.4», секцию, которой в файле уже нет; актуальное описание e2e — в
[development.md](development.md).
3. Перецелить оставшиеся ссылки из документации на
[development.md](development.md) / [security.md](security.md) /
[roadmap.md](roadmap.md).
4. Удалить [v1.x-closure-plan.md](v1.x-closure-plan.md) в том же или следующем
коммите.
**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит
git-тег `vX.Y.Z`; `implementation-plan.md` и `v1.x-closure-plan.md` удалены,
ссылок на них в активных документах и в коде/CI не осталось.
(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо
содержит файлы, либо отсутствует» удовлетворяет; Quick start в
[README.md](../README.md) тянет `docker-compose.yml` и `.env.example` с
`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал
больше нет.)
**Сводный индекс документации в README.** ~~Ссылки на `docs/` разбросаны по
тексту README…~~ **Закрыто (v1.x-closure Фаза 3):** секция Documentation в
[README.md](../README.md) — единый список operator docs + roadmap.
**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не
опрашивает сервер (фильтр на `htmx:beforeRequest` в
[panel.js](../internal/web/static/panel.js)). Остаток: вкладка на переднем
плане, с которой не работают, всё равно ходит раз в 5 с. Кандидат — адаптивный
интервал (5 с при активности, 30 с при простое) по `htmx:afterRequest` без
изменения `hx-trigger`. Ценность низкая: нагрузка — один SQL-запрос и рендер
фрагмента, так что это скорее гигиена, чем экономия. Допустимый исход —
осознанно не делать.~~ **Закрыто (v1.x-closure Фаза 1):** адаптивный интервал
5 с / 30 с / 0 (скрытая вкладка) в `panel.js` через `data-poll`.
**Send-log vs `mail.log`.** ~~Persist позиции чтения сделан (таблица
`logtail_state`, миграция `0003`): после рестарта панели log-tailer дочитывает
пропущенный хвост. Остаётся пересоздание контейнера — `mail.log` не в `/data` и
теряется вместе с ним, такие строки навсегда останутся `queued`. Кандидаты, если
станет больно: volume для лога, сверка зависших строк через `postqueue`.~~
**Закрыто (v1.x-closure Фаза 2):** сделаны оба кандидата — `mail.log` переехал в
`/data/log/`, а строки, чьи delivery-строки потеряны безвозвратно, закрываются
сверкой с `postqueue -p` (grace 2 мин → `bounced`). As-built и оставшийся риск
(ложный `bounced`): [architecture.md](architecture.md) § Log tailer,
[security.md](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](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](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](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](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, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
---
## Роль администратора домена — кандидат на 2.x
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
панели, роли» к out of scope (один администратор), поэтому появление второго
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
---
## `CONTRIBUTING.md` — кандидат на 2.x
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
моделей по типу работы, протокол коммитов, требование
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
[development.md](development.md) (английский процесс) и в этом файле (открытая
работа, русский).
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
проект вовне: английская документация процесса ([development.md](development.md),
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
русском) и первый внешний интерес после публикации релиза.
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
проверкам перед PR и протокол коммитов; [development.md](development.md) не
дублирует его, а ссылается.
---
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
2.x выше добавляют в него код — роль администратора домена приносит
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
разрезан, либо зафиксировано, что он остаётся плоским.