diff --git a/.cursor/rules/agent-rules.mdc b/.cursor/rules/agent-rules.mdc index 3ed923f..ce4ab34 100644 --- a/.cursor/rules/agent-rules.mdc +++ b/.cursor/rules/agent-rules.mdc @@ -17,8 +17,8 @@ alwaysApply: true 8. Check licence compatibility of new Go dependencies (permissive or GPL-family for AGPL-3.0). -Model routing — `docs/progress.md` § «Model by task type». Pre-release security +Model routing — `docs/development.md` § «Model routing». Pre-release security **review** (not authorship) — Fable. -Commit protocol, CHANGELOG, and phase closure — `docs/progress.md` § «Commits» -and «Phase closure protocol». +Commit protocol, CHANGELOG, and phase closure — `docs/development.md` § +«Commits and release build» and «Phase closure». diff --git a/CHANGELOG.md b/CHANGELOG.md index 67107ee..a3f65dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version is visible but idle, and no requests while the tab is hidden. Scheduling lives in `panel.js` (`data-poll` markers) instead of `hx-trigger="every …"`, which would need `unsafe-eval` under the panel's CSP. +- Documentation package consolidated into `docs/development.md`: Documentation + map, user-facing deliverables, maintenance rules, and code-to-prose + verification table (from closed `documentation-plan.md`); resuming work, + model routing, commits, and phase closure (from closed `progress.md`). + `docs/archive/` removed — history is git + CHANGELOG. README Documentation + index lists operator docs plus the internal roadmap. Agent rules point at + `development.md`. - The delivery page is laid out in two columns: what the journal recorded on the left, what happened to the message on the right, and the delivery log at full width under both. The facts the page used to stack one per line — domain, @@ -72,7 +79,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version testing, and CI; agent rules moved to `.cursor/rules/agent-rules.mdc`; dev-host-specific workflow and `example.com` references removed from docs. - `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to - English; `progress.md` and `roadmap.md` remain Russian (internal tracker). + English; `roadmap.md` remains Russian (internal tracker). ## [0.6.0] - 2026-08-08 diff --git a/README.md b/README.md index bc4fd20..129f68a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ send log and DNS checks in the panel, encrypted backups. | [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model | | [Architecture](docs/architecture.md) | As-built technical design | | [Security](docs/security.md) | Accepted security trade-offs and requirements | -| [Development](docs/development.md) | Building, testing, and contributing | +| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits | +| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian | | [CHANGELOG](CHANGELOG.md) | Release history | Repository: — source, issues, releases, and diff --git a/docs/architecture.md b/docs/architecture.md index db44a53..9726322 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ **Source of truth:** the code tree, not historical specs. Synchronise this file when env keys, routes, or mail-path behaviour change. Verification method: -[documentation-plan.md](documentation-plan.md) §2. +[development.md](development.md) § «Verifying docs against code». User install/operations: [README.md](../README.md), [guide.md](guide.md). Product boundaries: [product.md](product.md). diff --git a/docs/archive/specification-v1.0.md b/docs/archive/specification-v1.0.md deleted file mode 100644 index a9ebcae..0000000 --- a/docs/archive/specification-v1.0.md +++ /dev/null @@ -1,448 +0,0 @@ -> **Исторический снимок v1.0, не источник истины.** Актуальные документы: -> [product.md](../product.md), [architecture.md](../architecture.md), -> [development.md](../development.md), [security.md](../security.md), -> [README.md](../../README.md). - -# Техническое задание: SelfPost - -**Версия:** 1.0 -**Тип проекта:** self-hosted SMTP relay с веб-панелью управления -**Формат поставки:** один Docker-образ - ---- - -## 1. Цель и назначение - -SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для **исходящей отправки** почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель. - -**Ключевой сценарий использования:** пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена. - ---- - -## 2. Контекст и ограничения - -Эти ограничения — результат предварительного анализа и **не подлежат пересмотру** без явного согласования: - -1. **Развёртывание — VPS или домашний сервер** (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже). -2. **Отправка с собственного IP** (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS. -3. **Один контейнер.** Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания. -4. **Панель управления публично доступна** из интернета. Это накладывает обязательные требования к безопасности (раздел 7). - -> **Предпосылки инфраструктуры (вне зоны ответственности проекта).** SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо. - ---- - -## 3. Что НЕ входит в объём (Out of Scope) - -Явно исключено, чтобы не было scope creep: - -- ❌ Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики) -- ❌ Веб-почта (webmail) -- ❌ Мультитенантность (организации, несколько **пользователей панели**, роли) — панель рассчитана на **одного** администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1). -- ❌ Антиспам/антивирус для входящей почты (rspamd, ClamAV) -- ❌ Реализация собственного SMTP-сервера или MTA — используется готовый Postfix -- ❌ Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (`sasldb2`), см. раздел 5.1 - ---- - -## 4. Архитектура - -Единый Docker-образ на базе **Debian slim** (например, `debian:bookworm-slim`). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением `supervisord`: - -``` -┌───────────────────────────────────────────────┐ -│ Docker-контейнер SelfPost │ -│ │ -│ supervisord (PID 1) │ -│ ├── postfix (start-fg) │ -│ ├── opendkim (foreground) │ -│ └── panel (Go-бинарник, один процесс, │ -│ совмещает несколько ролей: │ -│ • HTTP-сервер панели :8080 │ -│ • journal-milter (приём :internal │ -│ From/To/Subject/SASL-user на этапе │ -│ DATA, см. 7.3) │ -│ • log-tailer — горутина, следит за │ -│ mail.log и обновляет статус доставки) │ -│ │ -│ Общая файловая система: │ -│ /etc/postfix/... ← панель пишет │ -│ /etc/opendkim/keys ← DKIM-ключи │ -│ /data/selfpost.db ← SQLite: домены, │ -│ приложения, администратор, журнал │ -│ отправки (см. 7.3, 9) │ -│ │ -│ Порты: │ -│ 465 (smtps, implicit TLS — основной, только │ -│ SASL-аутентификация) │ -│ 587 (submission, STARTTLS — опционально, │ -│ добавляется по необходимости, раздел 5) │ -│ 25 (исходящий в интернет) │ -│ 8080 (панель, за reverse-proxy) │ -└───────────────────────────────────────────────┘ -``` - -**Взаимодействие панели и Postfix:** панель пишет конфиг-файлы напрямую в локальную ФС (`/etc/postfix/...`) и вызывает `postfix reload` как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами **не нужны**. - -**Milter-цепочка Postfix теперь состоит из двух шагов:** OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в `smtpd_milters`/`non_smtpd_milters`. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника. - -**Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера.** `priority=` в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому: -1. `priority`: OpenDKIM стартует первым, затем panel (включая journal-milter listener). -2. **Postfix оборачивается стартовым скриптом**, который блокируется в цикле опроса (например, `test -S ` для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает `postfix start-fg` только после того, как **оба** milter-сокета подтверждённо готовы. -3. **Если готовность не наступает в пределах таймаута** — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а. -4. Это решает только **проблему холодного старта** (первые секунды жизни контейнера) и не отменяет уже принятое поведение для **runtime**-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента. - -**Требование к supervisord:** если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с `restart: unless-stopped` корректно его перезапустил. - -### 4.1. Доменная модель (ядро продукта) - -SelfPost — **мультидоменный** релей. Две связанные сущности: - -- **Отправляющий домен** — например, `example.com`. Имеет свой DKIM-ключ/селектор. -- **Приложение (учётная запись)** — SASL-пара логин/пароль, привязанная к конкретному домену. **У одного домена может быть несколько приложений** (например, `newsletter@example.com` и `alerts@example.com`, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать **только от имени того домена, к которому привязаны**, никогда от имени другого домена. - -**Режим адресов отправки — настройка на уровне приложения**, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов: - -1. **Любой адрес домена** — приложению разрешено указывать в From любой адрес в пределах своего домена (`*@example.com`). Удобно, когда одно приложение шлёт от разных адресов (например, `noreply@`, `alerts@`, `billing@`) без необходимости заводить под каждый отдельное приложение. -2. **Список конкретных адресов** — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только `alerts@example.com`). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену. - -В обоих случаях адрес обязан принадлежать домену приложения — выйти за пределы своего домена нельзя ни в одном режиме. - -Для каждого добавленного домена система управляет: - -1. **DKIM-ключ и селектор** — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена. -2. **Одно или несколько приложений** — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие. -3. **Разрешённые From-адреса** — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1). - -**Жизненный цикл домена (что делает панель при добавлении):** -- создаёт запись домена; -- генерирует DKIM-ключ + селектор для домена; -- показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10); -- **не создаёт приложение автоматически** — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже. - -**Жизненный цикл приложения (что делает панель при добавлении приложения к домену):** -- генерирует SASL-пару логин/пароль (пароль показывается **один раз**, см. 7.2); -- принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения); -- регистрирует привязку в `smtpd_sender_login_maps` соответственно режиму — либо wildcard-запись `@example.com → логин`, либо отдельная запись на каждый разрешённый адрес `конкретный-адрес@example.com → логин` (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение); -- применяет изменения (`postfix reload`). - -**Изменение режима существующего приложения** — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в `smtpd_sender_login_maps` и `postfix reload`. - -**При удалении домена** — удаляются его DKIM-ключ **и все приложения**, привязанные к этому домену. -**При удалении приложения** — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются. - -Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки. - ---- - -## 5. Компонент: Postfix - -Конфигурация Postfix как исходящего релея: - -1. **Приём почты — порт 465 (`smtps`, implicit TLS) как основной.** TLS устанавливается сразу при подключении (wrapper mode, `smtpd_tls_wrappermode = yes` в `master.cf` для сервиса `smtps`), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. **Только для аутентифицированных клиентов (SASL, см. 5.1).** `mynetworks` не используется как способ авторизации отправки — пускают креды, а не сеть. **Категорически недопустима конфигурация open relay.** - - **Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости.** Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис `submission` в `master.cf` включается тем же способом, что и `smtps` (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости. -2. **Отправка** — напрямую в интернет (без `relayhost`), с MX-lookup и TLS (`smtp_tls_security_level = may` минимум). -3. **DKIM** — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6). -4. **Ограничения** — `smtpd_recipient_restrictions` запрещает релей для неаутентифицированных (`permit_sasl_authenticated`, `reject_unauth_destination`). Обязательна привязка отправителя к логину (раздел 5.1). -5. **Базовый rate-limiting по client IP** — встроенный механизм Postfix (демон `anvil`): `smtpd_client_message_rate_limit` + `anvil_rate_time_unit` ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, **независимо от journal-milter** — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587). -6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим `postfix reload`). - -Базовый образ-ориентир для понимания подхода: `boky/postfix` (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера. - -### 5.1. SASL-аутентификация и привязка приложений к домену - -1. **Бэкенд SASL — Cyrus SASL с локальной базой `sasldb2`** (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент `saslpasswd2`), Postfix проверяет их через `smtpd_sasl_auth_enable = yes`. -2. **TLS обязателен для аутентификации на обоих портах, но механизм различается:** - - На **465 (`smtps`)** — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх `smtpd_tls_wrappermode = yes`. - - На **587 (`submission`, если включён)** — `smtpd_tls_auth_only = yes`, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу. - В обоих случаях результат одинаков: креды никогда не передаются в открытом виде. -3. **Привязка логина к разрешённым адресам (критично).** Настраивается `smtpd_sender_login_maps` + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1): - - **режим «любой адрес домена»** — wildcard-запись `@example.com логин` (Postfix поддерживает доменные wildcard-записи в `sender_login_maps`); - - **режим «список конкретных адресов»** — отдельная запись на каждый разрешённый адрес: `alerts@example.com логин`, `noreply@example.com логин` и т.д. - Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима. -4. **Многие-ко-одному.** У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано. - -### 5.2. TLS-сертификаты для Postfix - -**SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов** (ACME/Let's Encrypt). Проект **не** содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые. - -Модель простая: - -1. **Источник** — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost **bind mount'ом, только на чтение** (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8). -2. **Потребление** — Postfix настроен читать эти файлы (`smtpd_tls_cert_file` / `smtpd_tls_key_file`) для TLS на 465 (`smtps`, основной), 587 (`submission`, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма. -3. **Соответствие hostname** — CN/SAN сертификата должен совпадать с HELO-hostname (`SELFPOST_HOSTNAME`) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy. -4. **Обновление** — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через `postfix reload`. Чтобы деплой оставался простым, применяется **периодический `postfix reload` (например, раз в сутки)** — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее. - -Персистентность сертификатов (хранение, ACME-account key) — **ответственность reverse-proxy**, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте. - ---- - -## 6. Компонент: OpenDKIM - -DKIM-подпись — **строго per-domain**. Каждый отправляющий домен имеет собственную пару ключей и селектор. - -1. **Генерация при добавлении домена** — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны **переживать перезапуск контейнера** (см. раздел 9). -2. **Подпись** — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с `KeyTable` + `SigningTable`, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM. -3. **Показ DNS-записи per-domain** — панель показывает публичную часть ключа в формате DNS TXT-записи **для каждого домена отдельно**, чтобы пользователь внёс её в DNS соответствующего домена. -4. **Селектор** — на домен; может быть общим значением по умолчанию (например, `selfpost`) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое. -5. **При удалении домена** — его ключ и записи в `KeyTable`/`SigningTable` удаляются, OpenDKIM перечитывается. - ---- - -## 7. Компонент: Панель управления - -### 7.1. Технологический стек - -- **Язык: Go.** Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка. -- **Зависимости — минимальные.** Приоритет стандартной библиотеки: `net/http`, `html/template`, `os/exec`. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из `golang.org/x/crypto`). Каждая внешняя зависимость должна быть обоснована. -- HTTP-сервер слушает `:8080` внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10). - -**Front-end:** - -- **Server-rendered** через `html/template`. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль `node_modules`. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки. -- **HTMX** — единственная фронтенд-зависимость, подключается одним статическим `