7c79c085e7
Fold documentation-plan and progress into development.md, drop docs/archive, retarget live links, and point README plus agent-rules at the new home. Co-Authored-By: Composer <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
26 KiB
Markdown
192 lines
26 KiB
Markdown
# Дорожная карта: 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 `[Unreleased]/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:182](../internal/web/web.go:182)), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([handlers_monitor.go:49](../internal/web/handlers_monitor.go:49)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/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` — самый крупный пакет проекта: 47 файлов, ~3030
|
||
строк, в одной плоскости лежат хендлеры всех разделов панели, сессии,
|
||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
||
панели.
|
||
|
||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
||
2.x выше добавляют в него код — роль администратора домена приносит
|
||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
||
|
||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
||
разрезан, либо зафиксировано, что он остаётся плоским.
|