From 295d1afb7d8186740fd5c3c5981554e2e0703fd8 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sun, 9 Aug 2026 22:50:38 +0300 Subject: [PATCH] docs: translate the three plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap links straight into these files, so a reader following an item landed in Russian one click after an English page. Translated in full; goals, boundaries, done-when criteria and risks are unchanged in substance. The model assigned to inbound-relay is dropped, as it was from the roadmap item: model routing lives in development.md. Figures and code references were checked against the tree while translating rather than copied forward — internal/web is still 50 files, 25 .go, ~4300 lines, and requireAuth(authed) and sendLogData are still where domain-admin.md says they are. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 + docs/plans/domain-admin.md | 100 ++++++------- docs/plans/inbound-relay.md | 271 +++++++++++++++++++----------------- docs/plans/web-split.md | 74 +++++----- 4 files changed, 234 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 589d09c..7d60788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version are no dates, and the stated order is a recommendation. The model assigned to an item is no longer recorded there. The README row no longer calls the file internal and Russian, because it is neither. +- The three plans under [docs/plans/](docs/plans/) are now in English, matching + the roadmap that links to them. The model assigned to a plan is no longer + recorded in it. Package sizes quoted in `web-split.md` were re-checked + against the tree and still hold (50 files, 25 `.go`, ~4300 lines). - The two remaining Russian source comments are in English: `deploy/traefik/extract-cert.sh` (quote from spec 10.3) and `internal/app/sasl.go`, where the quotation from the closed plan is dropped diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md index e4efbfb..4384722 100644 --- a/docs/plans/domain-admin.md +++ b/docs/plans/domain-admin.md @@ -1,70 +1,72 @@ -# План: domain-admin (роль администратора домена) +# Plan: domain-admin (domain administrator role) -**Статус:** согласовано -**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа -в глобального. -**Порядок:** рекомендуется после [web-split](web-split.md), до +**Status:** agreed +**Version:** target bump **1.x** MINOR, given a compatible migration of the +current administrator into a global one. +**Order:** recommended after [web-split](web-split.md), before [inbound-relay](inbound-relay.md). --- -## Что это +## What this is -Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль -([web.go](../../internal/web/web.go) — обёртка -`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта -входа. +Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a +role ([web.go](../../internal/web/web.go) — the +`mux.Handle("/", s.requireAuth(authed))` wrapper), and the session carries +nothing beyond the fact of being signed in. -Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким); -перечень доменов определяет **глобальный администратор**. Для каждого домена из -списка: +The role grants access to **explicitly assigned domains** (one or several); the +list of domains is set by the **global administrator**. For each domain on that +list: -- приложения этого домена (создание, режим отправителя, перегенерация пароля, - удаление, свой L2-лимит); -- DKIM/DNS-статус домена; -- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть +- that domain's applications (creation, sender mode, password regeneration, + deletion, its own L2 limit); +- the domain's DKIM/DNS status; +- the send log filtered to the domain — the filter already exists in the log ([sendLogData](../../internal/web/handlers_monitor.go)). -Вне роли остаётся то, что глобально по своей природе: +What stays outside the role is what is global by nature: -- добавление и удаление доменов; -- создание domain-admin пользователей и назначение им доменов; +- adding and removing domains; +- creating domain-admin users and assigning domains to them; - `/reload`; -- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены - сразу); -- очередь и хвост `mail.log` — они серверные и к домену не привязаны. +- the full backup (that is all of `/data` including `sasldb2`, i.e. every + domain at once); +- the queue and the `mail.log` tail — those are server-wide and not tied to a + domain. -## Почему расширение v1.0 +## Why this extends v1.0 -[product.md](../product.md) относит «несколько пользователей панели, роли» к -out of scope (один администратор). Появление второго субъекта — сознательное -расширение границ проекта, как и inbound-relay. +[product.md](../product.md) puts "multiple panel users, roles" out of scope +(one administrator). A second subject is a deliberate widening of the project's +boundary, as inbound-relay is. -Цена — уровня фазы, а не патча: +The cost is phase-sized, not patch-sized: -- таблица пользователей и их привязка к доменам; -- роль в сессии; -- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` - не сверяются ни с чем, кроме существования); -- пересмотр первичного setup'а и смены пароля под нескольких пользователей; -- учёт нового субъекта в бэкапе и экспорте домена. +- a users table and their binding to domains; +- the role in the session; +- authorisation in every handler (not only on the route — today `{id}`/`{aid}` + are checked for nothing beyond existence); +- reworking first-run setup and password change for several users; +- accounting for the new subject in backup and domain export. -*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — -заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до -одной конкретной роли, потому что нужна не вторая копия всевластного админа, а -ограниченный доступ владельца одного или нескольких доменов — перечень задаёт -глобальный администратор.)* +*(The earlier wording of this item — "2FA and multiple administrators" — has +been replaced: 2FA is off the table, and "multiple administrators" is narrowed +to one specific role, because what is needed is not a second all-powerful admin +but limited access for the owner of one or several domains, with the list set +by the global administrator.)* -## Готово, когда +## Done when -- Глобальный администратор и domain-admin с разными правами работают через - панель; domain-admin не может выйти за пределы **назначенных** доменов; -- текущий единственный админ мигрирует в глобального без потери доступа; -- бэкап/восстановление учитывает пользователей и привязки; -- `build`/`vet`/`test`/образ зелёные. +- A global administrator and a domain-admin with different rights both work + through the panel; the domain-admin cannot reach past the **assigned** + domains; +- the current single admin migrates into a global one without losing access; +- backup/restore accounts for users and their bindings; +- `build`/`vet`/`test`/image green. -## Риски +## Risks -- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому - домену; -- breaking setup/бэкап — тогда semver major, не 1.x. +- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone + else's domain; +- breaking setup or backup — that would be a semver major, not 1.x. diff --git a/docs/plans/inbound-relay.md b/docs/plans/inbound-relay.md index dad4796..e27311d 100644 --- a/docs/plans/inbound-relay.md +++ b/docs/plans/inbound-relay.md @@ -1,159 +1,168 @@ -# План: inbound-relay (входящий релей) +# Plan: inbound-relay (inbound relay) -**Статус:** согласовано -**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по -итогам реализации (не фиксировать major заранее). -**Модель:** Opus (инфра/безопасность, риск open relay). -**Порядок:** рекомендуется после [web-split](web-split.md) и +**Status:** agreed +**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once +the implementation lands (do not fix a major in advance). +**Order:** recommended after [web-split](web-split.md) and [domain-admin](domain-admin.md). --- -## Цель +## Goal -Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать -её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как -выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность -атаки базового исходящего релея. +The ability to accept mail on port 25 for explicitly configured domains and +forward it to a given upstream backend (a backup-MX / relay-forwarder role), as +a **module disabled by default** that changes neither the behaviour nor the +attack surface of the base outbound relay. -## Зачем это нужно (сценарии) +## What it is for (scenarios) -- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно - недоступен, и передать её, когда он вернётся. -- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, - который по каким-то причинам **сам не может принимать почту из интернета** - (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на - входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает - публичным входным узлом для домена (MX указывает на него) и пересылает почту - на этот внутренний/недоступный извне сервер. +- **Backup-MX** — accept mail while the domain's primary mail server is + temporarily unreachable, and hand it over when it comes back. +- **A front for a server without a public IP** — the operator runs their own + mail server which, for whatever reason, **cannot accept mail from the + internet itself** (no static or public IP, behind NAT, a private address, + inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct + PTR, acts as the domain's public entry node (the MX points at it) and + forwards mail to that internal or otherwise unreachable server. -## Граница объёма (критично — что это НЕ) +## Scope boundary (critical — what this is NOT) -- **ЭТО:** приём на 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 (см. блок «Антиспам» ниже): предоставляет - точку подключения внешнего фильтра. +- **IT IS:** acceptance on 25 for domains from an explicit list, plus + forwarding (relay/forward) to an upstream (`relay_domains` + + `transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder, + with no local delivery. +- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to + mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also + **neither implements nor bundles** an anti-spam or anti-virus engine + (rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push + filtering onto the backend either (see the "Anti-spam" section below): it + provides an attachment point for an external filter. -## Почему как опция/плагин +## Why as an option / plugin -- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, - spam-ingress). Поэтому по умолчанию **выключено** флагом env - `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора. -- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, - отдельная ветка генерации конфига. При выключенном флаге входной listener, - таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен. +- Accepting on port 25 changes the threat model (open relay for inbound, + backscatter, spam ingress). So it is **off** by default behind the + `INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by + the operator. +- Isolation: separate SQLite tables, separate panel handlers and pages, a + separate branch of config generation. With the flag off, the inbound + listener, the tables and the UI are absent — the base outbound path is + byte-for-byte unchanged. -## Что делать +## What to do -- 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. +- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate + the inbound service and its config from panel state the same way the rest of + the config is generated (`postfix-config.sh`). +- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet + (today 25 is used only for outbound delivery). Separate from 465/587: on 25 + SASL is **not** offered and sending outwards is **not** allowed — inbound + only, for `relay_domains`. +- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's + `smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail + **only** for domains in `relay_domains` and **only** for known recipients + (`relay_recipient_maps`); everything else gets + `reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or + accepting "for anyone", is impossible. +- **Backscatter:** knowing the valid recipients is preferable (reject unknown + recipient at RCPT stage) so that bounces to non-existent addresses are never + generated. +- **The panel manages:** the list of inbound domains; for each one the upstream + destination (`host:port`, transport), an optional list of valid recipients, + and optional TLS to the upstream. Strict validation of domain, host and port + (whitelist), injection-safe writing of map files (as with + `sender_login_maps` in Phase 4), `os/exec` without a shell + ([security.md](../security.md)). +- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign + someone else's inbound mail). The journal-milter can optionally be reused for + an inbound journal (extra work), or the inbound path can go without it in the + first stage; fail-open behaviour is preserved. +- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and + `message_size_limit` on the inbound smtpd. -## Антиспам (важная, но опциональная возможность) +## Anti-spam (important, but optional) -Это ценная опция, но она **не обязательна**: часть операторов вполне устроит -**слепая пересылка без фильтрации** — например, когда backend сам умеет -фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. -Поэтому антиспам-хук по умолчанию **выключен** (пустой -`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. +This is a valuable option, but it is **not mandatory**: some operators will be +perfectly served by **blind forwarding without filtering** — when the backend +can filter on content itself, when the upstream is trusted, or when the volume +and risk are low. So the anti-spam hook is **off** by default (an empty +`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without +it. -Важно другое — где фильтрация возможна технически: при «слепом» relay целевой -backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, -поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация -проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF -домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — -входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть -*подключаема именно здесь*, а не переложена на backend, который эту информацию -уже потерял. +What matters is something else: where filtering is technically possible. With a +"blind" relay the destination backend sees **SelfPost's** address as the +connecting IP, not the original sender's, so everything on the backend that +depends on the origin IP breaks (DNSBL and reputation are checked against +SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's +SPF). **The only point where the real client IP is still visible is the inbound +hop at SelfPost** — so for those who need filtering, it has to be *attachable +right here*, not delegated to a backend that has already lost the information. -Дизайн подключения: +The attachment design: -- **Движок антиспама — отдельный опциональный контейнер** (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. +- **The anti-spam engine is a separate optional container** (rspamd or + similar), which the operator runs **only if this option is wanted** (the same + principle as the reverse proxy — a separate container outside the SelfPost + image). SelfPost **neither contains nor starts it** — the image and the "one + container, three processes" principle are unchanged, and + [product.md](../product.md)'s out-of-scope list is not violated (SelfPost + does not implement anti-spam). +- **SelfPost provides the attachment point:** a milter hook on the inbound + smtpd. The engine's address is set via env (for example, + `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and + is added to `smtpd_milters` for the **inbound path only** (not on 465/587). + Postfix passes the milter the real client IP, HELO and PTR — the filter sees + the true origin. `milter_default_action` for that milter is configurable + (fail-open vs tempfail); the default is to be decided during implementation. +- **A native backstop with no dependencies:** on that same inbound hop, + Postfix's own origin-IP facilities are available — `reject_rbl_client` + (DNSBL) and HELO/PTR checks — and they work even without an external + container. Plus preserving authentication results for downstream through ARC + or `Received`, where part of the filtering does remain on the backend. +- **docker-compose:** document an optional anti-spam sidecar fragment (like the + alternative reverse-proxy fragments) — the container comes up with the stack + only when the option is enabled. +- **Persistence:** new tables and map files under `/data` — they land in the + full backup automatically (Phase 9). Domain export/import can be extended + with the inbound configuration — optional, to be flagged. +- **DNS documentation:** an inbound domain needs an `MX` record pointing at the + server (unlike outbound, where no MX is required) — to be reflected in the + README's DNS section. -## Безопасность +## Security -[security.md](../security.md): валидация ввода на сервере, экранирование записи -в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter. +[security.md](../security.md): server-side input validation, escaped writes to +config files, `exec` without interpolation, no open relay, protection against +backscatter. -## Готово, когда +## Done when -При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого -домена пересылается на заданный upstream; почта для ненастроенных -доменов/получателей отклоняется (не open relay, не backscatter); при заданном -`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим -origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при -`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый -исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные. +With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port +25 for that domain is forwarded to the given upstream; mail for unconfigured +domains or recipients is rejected (not an open relay, no backscatter); with +`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter +with the real origin IP (verified with a sidecar container), and with it empty +the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound +port, tables and UI are absent and the base outbound relay is unchanged; +`build`/`vet`/`test`/image green. -## Риски +## Risks -- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` + - `reject_unauth_destination`; -- потеря origin IP для фильтрации на backend'е при пересылке — снимается - milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё - виден; -- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено); -- semver: при несовместимости контракта (порты, бэкап, поведение без флага) — - возможен major `2.x`; решение после реализации. +- open relay / backscatter — removed by `relay_domains` + + `relay_recipient_maps` + `reject_unauth_destination`; +- the loss of the origin IP for filtering on the backend when forwarding — + removed by the anti-spam milter hook plus native DNSBL on the inbound hop, + where the origin IP is still visible; +- port 25 accepting mail widens the attack surface (off by default); +- semver: if the contract turns out incompatible (ports, backup, behaviour with + the flag off) a major `2.x` is possible; the decision comes after the + implementation. -**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа -SelfPost, поднимается оператором при включении опции. +**External deployment dependency:** the optional anti-spam container — outside +the SelfPost image, brought up by the operator when the option is enabled. -## Зависимости +## Dependencies -Готовый исходящий тракт (уже реализован). Согласование получено — см. статус -выше. +A finished outbound path (already implemented). Agreement obtained — see the +status above. diff --git a/docs/plans/web-split.md b/docs/plans/web-split.md index c9f8066..e2845a2 100644 --- a/docs/plans/web-split.md +++ b/docs/plans/web-split.md @@ -1,52 +1,56 @@ -# План: web-split (разбиение `internal/web`) +# Plan: web-split (splitting `internal/web`) -**Статус:** согласовано -**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет. +**Status:** agreed +**Version:** `1.x`; an internal refactor, it does not force a break on its own. --- -## Что это +## What this is -`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и -static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех -разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и -рендер шаблонов. +`internal/web` is the project's largest package: ~50 files (templates and +static assets included), ~25 `.go` files and ~4000 lines of Go, with the +handlers for every panel section, sessions, security headers, origin checking, +form validation and template rendering all sitting in one flat namespace. -Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам -панели. +The candidates to split out are `web/handlers` and `web/auth`, or a cut along +the panel's own domains. -## Почему сейчас +## Why now -На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`, -`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а -разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то -есть расширение внутреннего API ради косметики. +At its current size the flat package reads fine: the file names +(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work +directories would, and splitting would force exporting what is package-private +today — widening the internal API for cosmetics. -Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay** -добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий -релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле -делать перед этим ростом, чем после. +It starts to pay off once the package grows: **domain-admin** and +**inbound-relay** both add code to it — the role brings authorisation into +every handler, the inbound relay brings its own pages and handlers for inbound +domains. The refactor is cheaper before that growth than after it. -## Рекомендуемый порядок +## Recommended order -**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)). +**web-split → domain-admin → inbound-relay** (see the +[roadmap](../roadmap.md)). -1. **web-split** — заложить структуру пакета (в т.ч. место под `web/auth`), пока - нет сквозных правок от роли и новых inbound-хендлеров. -2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную - схему пакета. -3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный - пакет, чем рефакторить вместе с двумя предыдущими фичами. +1. **web-split** — lay down the package structure (including a place for + `web/auth`) while there are no cross-cutting edits from the role and no new + inbound handlers. +2. **domain-admin** — authorisation in every handler builds on a package layout + already chosen. +3. **inbound-relay** — a new vertical slice; easier to add to an already split + package than to refactor alongside the two features before it. -Порядок рекомендация, не блокер. +The order is a recommendation, not a blocker. -## Готово, когда +## Done when -Решение принято осознанно в момент старта работ — либо пакет разрезан по -выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки: -`build`/`vet`/`test` зелёные, поведение панели неизменно. +The decision is made deliberately when the work starts — either the package is +split along the chosen scheme, or it is settled that it stays flat. After a +split: `build`/`vet`/`test` green, the panel's behaviour unchanged. -## Риски +## Risks -- Преждевременное разбиение — лишний внутренний API и churn без выгоды; -- откладывание до после роста — сложнее рефакторинг в перемешку с фичами. +- Splitting too early — a superfluous internal API and churn with nothing to + show for it; +- leaving it until after the growth — a harder refactor, tangled up with the + features.