From 9b3ce0b33e5d198a9e46e74a437870935a6f8a41 Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 9 Aug 2026 13:14:43 +0300 Subject: [PATCH] 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 --- README.md | 2 +- docs/development.md | 16 ++-- docs/plans/domain-admin.md | 64 +++++++++++++++ docs/plans/inbound-relay.md | 157 +++++++++++++++++++++++++++++++++++ docs/plans/web-split.md | 39 +++++++++ docs/product.md | 7 +- docs/roadmap.md | 160 +++++++++++++----------------------- 7 files changed, 335 insertions(+), 110 deletions(-) create mode 100644 docs/plans/domain-admin.md create mode 100644 docs/plans/inbound-relay.md create mode 100644 docs/plans/web-split.md diff --git a/README.md b/README.md index 65e36e7..4593429 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ send log and DNS checks in the panel, encrypted backups. | [Architecture](docs/architecture.md) | As-built technical design | | [Security](docs/security.md) | Accepted security trade-offs and requirements | | [Development](docs/development.md) | Building, testing, docs rules, model routing, commits | -| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian | +| [Roadmap](docs/roadmap.md) | Open work (1.x+) — internal, Russian | | [CHANGELOG](CHANGELOG.md) | Release history | Repository: — source, issues, releases, and diff --git a/docs/development.md b/docs/development.md index d98a8f9..7863d0f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,8 +1,9 @@ # SelfPost — development **What this file is.** How to build, test, document, and ship changes. Open -work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries: -[product.md](product.md). As-built layout: [architecture.md](architecture.md). +work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked +[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout: +[architecture.md](architecture.md). --- @@ -11,10 +12,12 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries: After `/clear` or a fresh chat: 1. Read this file (process, docs rules, model routing). -2. Open [roadmap.md](roadmap.md) for open work. Accepted risks — - [security.md](security.md); as-built — [architecture.md](architecture.md). +2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked + plan file for the active item. Accepted risks — [security.md](security.md); + as-built — [architecture.md](architecture.md). 3. Skim [product.md](product.md) if scope is in doubt. -4. Continue from the next unchecked step in the active plan. +4. Continue from the next unchecked step in the **active** plan file (not the + roadmap index). History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md), not duplicated here. @@ -273,7 +276,8 @@ There is no `docs/archive/` directory. | As-built design | [architecture.md](architecture.md) | | Development process (this file) | [development.md](development.md) | | Security requirements and accepted risks | [security.md](security.md) | -| Internal roadmap (2.x) | [roadmap.md](roadmap.md) | +| Internal roadmap (1.x+) | [roadmap.md](roadmap.md) | +| Active design plans | [plans/](plans/) | | Release history | [CHANGELOG.md](../CHANGELOG.md) | ### User-facing deliverables diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md new file mode 100644 index 0000000..cbfebec --- /dev/null +++ b/docs/plans/domain-admin.md @@ -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. diff --git a/docs/plans/inbound-relay.md b/docs/plans/inbound-relay.md new file mode 100644 index 0000000..e72f827 --- /dev/null +++ b/docs/plans/inbound-relay.md @@ -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, поднимается оператором при включении опции. + +## Зависимости + +Готовый исходящий тракт (уже реализован). Согласование получено — см. статус +выше. diff --git a/docs/plans/web-split.md b/docs/plans/web-split.md new file mode 100644 index 0000000..5c1c641 --- /dev/null +++ b/docs/plans/web-split.md @@ -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 без выгоды; +- откладывание до после роста — сложнее рефакторинг в перемешку с фичами. diff --git a/docs/product.md b/docs/product.md index 1ef0893..823e445 100644 --- a/docs/product.md +++ b/docs/product.md @@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep: - A custom MTA — Postfix is used as-is - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only -Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in -[roadmap.md](roadmap.md) and requires explicit approval before implementation. +Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are +tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets +a 1.x MINOR bump by default; a 2.x major remains possible pending +implementation. Items still marked *candidate* in the roadmap require explicit +approval before coding. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 950fc52..67cf7d2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,133 +1,91 @@ -# Дорожная карта: SelfPost 2.x.x +# Дорожная карта: открытая работа (1.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) явно исключает часть этого объёма (приём входящей -почты; несколько пользователей/роли), поэтому включение — сознательное -расширение границ проекта, а не доработка по своей инициативе. Присутствие -пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным -решением. +**Статус:** внутренний трекер расширений границ v1.0 после явного согласования +([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). +Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK +до кодирования. -**Основа:** [product.md](product.md) v1.0. Процесс и правила документации — -[development.md](development.md). История закрытых фаз v1.x — в `git log` и -[CHANGELOG.md](../CHANGELOG.md). +**Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если +дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking. + +**Процесс:** [development.md](development.md). История закрытых фаз — в `git log` +и [CHANGELOG.md](../CHANGELOG.md). --- -## v1.x — хвост документации и деплоя +## Индекс -**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0` -(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md` -удалены — история в git и CHANGELOG; `docs/archive/` не храним. +| ID | Тема | Статус | План | +|---|---|---|---| +| inbound-relay | Входящий релей (backup-MX / пересылка) | **согласовано** | [plans/inbound-relay.md](plans/inbound-relay.md) | +| domain-admin | Роль администратора домена | **согласовано** | [plans/domain-admin.md](plans/domain-admin.md) | +| web-split | Разбиение `internal/web` | **согласовано** | [plans/web-split.md](plans/web-split.md) | +| contributing | `CONTRIBUTING.md` | кандидат | — | -| Тема | Итог | -|---|---| -| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` | -| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` | -| Docs consolidation | процесс в [development.md](development.md); README Documentation | -| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите | - -Открытая работа дальше — только секции 2.x ниже. +Жёсткого порядка реализации нет. После `/clear` — пункт со статусом +`согласовано` или `в работе`, затем чеклист в linked plan. --- -## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин +## inbound-relay -**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея. +**Цель:** опциональный приём почты на порт 25 для явно настроенных доменов и +пересылка на upstream (backup-MX / relay-forwarder). По умолчанию выключено +(`INBOUND_RELAY_ENABLE=false`); исходящий тракт без флага не меняется. -**Зачем это нужно (сценарии):** -- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся. -- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер. +**Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей +почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне +образа, только точка подключения. -**Граница объёма (критично — что это НЕ):** -- **ЭТО:** приём на 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 (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра. +**Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md). -**Почему как опция/плагин:** -- Приём на порт 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)) до кодирования. +**Зависимости / риски:** готовый исходящий тракт; open relay/backscatter; +расширение поверхности атаки (порт 25 на приём). Модель: Opus. +**Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации. --- -## Роль администратора домена — кандидат на 2.x +## domain-admin -**Что это.** Сейчас в панели ровно один субъект: `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` — они серверные и к домену не привязаны. +**Цель:** роль с доступом к одному домену — приложения, DKIM/DNS, журнал +отправки по домену; без глобальных операций (добавление доменов, полный бэкап, +очередь, `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'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена. +**Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного +администратора. Не вторая копия всевластного админа, а ограниченный владелец +домена. -*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)* +**Готово, когда:** см. [plans/domain-admin.md](plans/domain-admin.md). + +**Зависимости / риски:** таблица пользователей, роль в сессии, авторизация в +каждом хендлере, setup/бэкап. **Версия:** `1.x` MINOR при совместимой миграции +текущего админа в глобального. --- -## `CONTRIBUTING.md` — кандидат на 2.x +## web-split -**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация -моделей по типу работы, протокол коммитов, требование -`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в -[development.md](development.md) (английский процесс) и в этом файле (открытая -работа, русский). +**Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет) +перед ростом от inbound-relay и domain-admin. -**Почему 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) не -дублирует его, а ссылается. +**Готово, когда:** пакет разрезан по выбранной схеме или зафиксировано, что +остаётся плоским — см. [plans/web-split.md](plans/web-split.md). + +**Зависимости / риски:** экспорт пакетно-приватного API. **Версия:** `1.x`, сам +по себе breaking не тянет. --- -## Разбиение `internal/web` на подпакеты — кандидат на 2.x +## contributing -**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов -(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости -лежат хендлеры всех разделов панели, сессии, -security-заголовки, проверка Origin, валидация форм и рендер шаблонов. -Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам -панели. +**Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол +коммитов; [development.md](development.md) ссылается, не дублирует. -**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена -файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) -работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что -сейчас пакетно-приватно, — то есть расширение внутреннего API ради -косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи -2.x выше добавляют в него код — роль администратора домена приносит -авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры -входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после. +**Граница:** документация процесса; уместна при внешнем потоке PR. -**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет -разрезан, либо зафиксировано, что он остаётся плоским. +**Готово, когда:** файл в корне; development.md не дублирует его. + +**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет. +**Версия:** без значения для semver.