docs: restructure roadmap as 1.x+ tracker with plan files

Split detailed design into docs/plans/ and keep roadmap as a status index;
align product, development, and README with the 1.x+ release line.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mixeme
2026-08-09 13:14:43 +03:00
parent e163fe123d
commit 012802d83d
7 changed files with 335 additions and 110 deletions
+64
View File
@@ -0,0 +1,64 @@
# План: domain-admin (роль администратора домена)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
в глобального.
---
## Что это
Сейчас в панели ровно один субъект: `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` — они серверные и к домену не привязаны.
## Почему расширение v1.0
[product.md](../product.md) относит «несколько пользователей панели, роли» к
out of scope (один администратор). Появление второго субъекта — сознательное
расширение границ проекта, как и inbound-relay.
Цена — уровня фазы, а не патча:
- таблица пользователей и их привязка к доменам;
- роль в сессии;
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
не сверяются ни с чем, кроме существования);
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
- учёт нового субъекта в бэкапе и экспорте домена.
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» —
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
ограниченный доступ владельца отдельного домена.)*
## Готово, когда
- Глобальный администратор и domain-admin с разными правами работают через
панель; domain-admin не может выйти за пределы своего домена;
- текущий единственный админ мигрирует в глобального без потери доступа;
- бэкап/восстановление учитывает пользователей и привязки;
- `build`/`vet`/`test`/образ зелёные.
## Риски
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
домену;
- breaking setup/бэкап — тогда semver major, не 1.x.
+157
View File
@@ -0,0 +1,157 @@
# План: inbound-relay (входящий релей)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по
итогам реализации (не фиксировать major заранее).
**Модель:** Opus (инфра/безопасность, риск open relay).
---
## Цель
Возможность принимать почту на порт 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 на приём расширяет поверхность атаки (по умолчанию выключено);
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
возможен major `2.x`; решение после реализации.
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
SelfPost, поднимается оператором при включении опции.
## Зависимости
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
выше.
+39
View File
@@ -0,0 +1,39 @@
# План: web-split (разбиение `internal/web`)
**Статус:** согласовано
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
---
## Что это
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
## Почему сейчас
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
есть расширение внутреннего API ради косметики.
Смысл появляется, когда пакет начнёт расти: **inbound-relay** и **domain-admin**
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
делать перед этим ростом, чем после.
## Готово, когда
Решение принято осознанно в момент старта работ — либо пакет разрезан по
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
`build`/`vet`/`test` зелёные, поведение панели неизменно.
## Риски
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.