docs: consolidate process docs into development.md (v1.x closure phase 3)
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>
This commit is contained in:
@@ -17,8 +17,8 @@ alwaysApply: true
|
|||||||
8. Check licence compatibility of new Go dependencies (permissive or GPL-family for
|
8. Check licence compatibility of new Go dependencies (permissive or GPL-family for
|
||||||
AGPL-3.0).
|
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.
|
**review** (not authorship) — Fable.
|
||||||
|
|
||||||
Commit protocol, CHANGELOG, and phase closure — `docs/progress.md` § «Commits»
|
Commit protocol, CHANGELOG, and phase closure — `docs/development.md` §
|
||||||
and «Phase closure protocol».
|
«Commits and release build» and «Phase closure».
|
||||||
|
|||||||
+8
-1
@@ -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
|
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 …"`,
|
lives in `panel.js` (`data-poll` markers) instead of `hx-trigger="every …"`,
|
||||||
which would need `unsafe-eval` under the panel's CSP.
|
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 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
|
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,
|
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`;
|
testing, and CI; agent rules moved to `.cursor/rules/agent-rules.mdc`;
|
||||||
dev-host-specific workflow and `example.com` references removed from docs.
|
dev-host-specific workflow and `example.com` references removed from docs.
|
||||||
- `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to
|
- `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
|
## [0.6.0] - 2026-08-08
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [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 |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Source of truth:** the code tree, not historical specs. Synchronise this file
|
**Source of truth:** the code tree, not historical specs. Synchronise this file
|
||||||
when env keys, routes, or mail-path behaviour change. Verification method:
|
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:
|
User install/operations: [README.md](../README.md), [guide.md](guide.md). Product boundaries:
|
||||||
[product.md](product.md).
|
[product.md](product.md).
|
||||||
|
|||||||
@@ -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-сокет>` для 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** — единственная фронтенд-зависимость, подключается одним статическим `<script>` (вендорится в бинарник/образ, без npm). Используется для:
|
|
||||||
- частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
|
|
||||||
- **автообновления экранов мониторинга** (очередь, лог) через периодический polling — `hx-trigger="every Ns"` подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
|
|
||||||
- **Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket.** Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
|
|
||||||
- Эндпоинты, отдающие фрагменты для polling (например, `/queue/fragment`, `/log/fragment`), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.
|
|
||||||
|
|
||||||
### 7.2. Функциональные требования
|
|
||||||
|
|
||||||
Панель предоставляет **ограниченный** набор операций:
|
|
||||||
|
|
||||||
1. **Аутентификация** — вход администратора по логину/паролю (раздел 7.6).
|
|
||||||
2. **Список доменов** — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
|
|
||||||
3. **Добавить домен** — по имени домена панель (см. раздел 4.1):
|
|
||||||
- создаёт запись домена;
|
|
||||||
- генерирует DKIM-ключ + селектор;
|
|
||||||
- применяет изменения (reload OpenDKIM);
|
|
||||||
- показывает DKIM TXT-запись для DNS.
|
|
||||||
Добавление домена **не создаёт приложение** — это отдельное действие (п. 5).
|
|
||||||
4. **Удалить домен** — удаляет DKIM-ключ домена **и все привязанные к нему приложения** (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
|
|
||||||
5. **Добавить приложение к домену** — внутри карточки домена, за одну операцию:
|
|
||||||
- генерирует SASL-пару логин/пароль;
|
|
||||||
- принимает выбор **режима адресов**: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
|
|
||||||
- регистрирует соответствующую(ие) запись(и) в `smtpd_sender_login_maps` (wildcard или по адресам — раздел 5.1);
|
|
||||||
- применяет изменения (`postfix reload`);
|
|
||||||
- **показывает сгенерированный пароль ОДИН РАЗ** (см. 7.6, п. про пароли).
|
|
||||||
6. **Список приложений домена** — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
|
|
||||||
7. **Редактировать режим адресов приложения** — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в `smtpd_sender_login_maps` и применяет изменения.
|
|
||||||
8. **Удалить приложение** — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
|
|
||||||
9. **Перевыпустить пароль приложения** — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
|
|
||||||
10. **Просмотр DKIM DNS-записи** — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
|
|
||||||
11. **Просмотр очереди** — вывод `mailq` / `postqueue -p` в читаемом виде (сколько писем в очереди, статусы). Экран **автообновляется** через HTMX-polling раз в несколько секунд (см. 7.1).
|
|
||||||
12. **Reload** — кнопка, применяющая изменения конфига вручную (`postfix reload` / reload OpenDKIM), если нужно.
|
|
||||||
13. **Просмотр хвоста почтового лога** для диагностики — последние N строк, с автообновлением через polling.
|
|
||||||
14. **Журнал отправки писем** — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
|
|
||||||
15. **Настройка лимитов отправки** — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
|
|
||||||
16. **Скачать полную резервную копию** — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
|
|
||||||
17. **Экспортировать домен** / **Импортировать домен** — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).
|
|
||||||
|
|
||||||
### 7.3. Журнал отправки (Send Log)
|
|
||||||
|
|
||||||
Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой `mailq`/hвост файла, а структурированная, фильтруемая история отправленных писем.
|
|
||||||
|
|
||||||
**Что фиксируется в журнале для каждого письма:**
|
|
||||||
- время приёма;
|
|
||||||
- домен и приложение (SASL-логин), от имени которого отправлено;
|
|
||||||
- From-адрес и To-адрес(а);
|
|
||||||
- Subject (тема);
|
|
||||||
- статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
|
|
||||||
- queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).
|
|
||||||
|
|
||||||
**Технический механизм получения данных:**
|
|
||||||
|
|
||||||
1. **Тема письма не попадает в стандартные логи Postfix** (`mail.log` содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует **лёгкий milter** (например, через библиотеку `github.com/emersion/go-milter` или аналог), подключённый в `smtpd_milters` Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
|
|
||||||
2. **Финальный статус доставки** panel получает отдельно: горутина внутри того же бинарника хвостит `mail.log` (аналогично экрану из п. 13), парсит строки со статусами `sent`/`bounced`/`deferred` по queue-id и обновляет соответствующую запись журнала.
|
|
||||||
3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит **отдельную запись на пару (queue-id, получатель)**, чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.
|
|
||||||
|
|
||||||
> **⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект.** В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:
|
|
||||||
> - **Протокол milter завязан на конкретные версии** (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
|
|
||||||
> - **Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую.** Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от `milter_default_action`) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
|
|
||||||
> - **Меньше протестированных Go-примеров** milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.
|
|
||||||
>
|
|
||||||
> **Требование по снижению риска:** `milter_default_action` для journal-milter должен быть настроен так, чтобы **сбой journal-milter не блокировал приём и отправку почты** (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.
|
|
||||||
|
|
||||||
**Хранение:** таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.
|
|
||||||
|
|
||||||
**Retention (обязательно предусмотреть, иначе журнал растёт бесконечно):** конфигурируемый срок хранения записей (например, `SEND_LOG_RETENTION_DAYS`, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Таблица: время, домен, приложение, From, To, Subject, статус.
|
|
||||||
- **Фильтры — обязательны:** по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с `WHERE`), не на клиенте.
|
|
||||||
- Пагинация или ограничение количества строк на экран (журнал может быть большим).
|
|
||||||
- Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.
|
|
||||||
|
|
||||||
**Приватность и безопасность — важное замечание:** журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.
|
|
||||||
|
|
||||||
### 7.4. Rate limiting (лимиты отправки)
|
|
||||||
|
|
||||||
Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.
|
|
||||||
|
|
||||||
**Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop).** Описан в разделе 5, п. 5: `smtpd_client_message_rate_limit`/`anvil_rate_time_unit`, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.
|
|
||||||
|
|
||||||
**Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели.** Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.
|
|
||||||
|
|
||||||
- **Ключ лимита — client IP, а не SASL-логин.** Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
|
|
||||||
- **Настройка:** при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
|
|
||||||
- **Проверка:** при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и **опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту»** — для видимости в UI.
|
|
||||||
- **Fail-open допустим на этом уровне.** Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.
|
|
||||||
|
|
||||||
> **⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения.** Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.
|
|
||||||
|
|
||||||
> **⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа.** Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.
|
|
||||||
|
|
||||||
### 7.5. Резервное копирование и миграция
|
|
||||||
|
|
||||||
Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.
|
|
||||||
|
|
||||||
**А. Полный бэкап сервера (миграция на новую машину целиком).**
|
|
||||||
|
|
||||||
- **Что входит в архив:** консолидированное персистентное состояние — SQLite (`selfpost.db`: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (`sasldb2`), и **манифест с версией SelfPost**, которой создан бэкап (см. ниже). **Рекомендация исполнителю:** организовать все три под единым корневым путём (например, всё под `/data/`), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
|
|
||||||
- **Что НЕ входит:** TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (`/var/spool/postfix`, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
|
|
||||||
- **Версионирование бэкапа — обязательно.** Архив содержит файл-манифест (например, `manifest.json`) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, `-X main.version=...`, согласуется с тегом Docker-образа). Восстановление **должно происходить в тот же самый Docker-образ той же версии**, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата `sasldb2` между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
|
|
||||||
- **Проверка при восстановлении:** при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — **отказ от запуска/восстановления с понятным сообщением**, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте `selfpost:1.3.0` для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
|
|
||||||
- Эта версия **не про автомиграцию на лету между версиями** — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
|
|
||||||
- **Следствие для деплоя:** `docker-compose.yml` должен использовать **фиксированный тег версии образа**, не `:latest` — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
|
|
||||||
- **Создание бэкапа — двумя равнозначными способами:**
|
|
||||||
1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
|
|
||||||
2. Эквивалентная CLI-утилита внутри контейнера (например, `selfpost-backup`, вызываемая через `docker exec`) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
|
|
||||||
- **Восстановление на новом сервере:** поднять пустой контейнер SelfPost **той же версии образа**, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них **до первого старта** (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
|
|
||||||
- **Прямое следствие для DNS:** поскольку DKIM-ключи переносятся побитово, **DKIM TXT-записи в DNS не нужно менять** после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
|
|
||||||
- **Безопасность архива:** архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.
|
|
||||||
|
|
||||||
**Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).**
|
|
||||||
|
|
||||||
- **Экспорт домена** формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и **соответствующими записями `sasldb2` для приложений этого домена**. Технически это возможно, потому что `sasldb2` (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом `sasldb2` целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
|
|
||||||
- **Импорт на другом экземпляре** восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения **с рабочими паролями без перевыпуска** — по тому же принципу, что и полный бэкап.
|
|
||||||
- **Безопасность экспортного файла — как у полного бэкапа.** Поскольку файл теперь содержит секреты приложений (записи `sasldb2`) и приватный DKIM-ключ домена, он **настолько же чувствителен**, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».
|
|
||||||
|
|
||||||
### 7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)
|
|
||||||
|
|
||||||
Поскольку панель публична, следующее — **не опционально**:
|
|
||||||
|
|
||||||
1. **Первичная инициализация администратора — через одноразовую секретную ссылку**, не через env-переменную с готовым хэшем:
|
|
||||||
- При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида `https://<host>/setup/<token>` в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, `/data/setup-token`) — на случай, если удобнее прочитать файл, чем логи.
|
|
||||||
- **Энтропия токена — не менее 128 бит** (например, 16+ случайных байт из `crypto/rand`, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
|
|
||||||
- **Срок жизни токена — 10 минут** (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к `/setup` (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
|
|
||||||
- **Rate limiting на маршрут `/setup/<token>`** — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
|
|
||||||
- **Сравнение токена — константное по времени** (`crypto/subtle.ConstantTimeCompare` или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
|
|
||||||
- **Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно.** Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
|
|
||||||
- Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
|
|
||||||
- После успешного создания администратора токен **инвалидируется навсегда** (флаг в персистентном состоянии), маршрут `/setup/*` перестаёт быть доступен (404).
|
|
||||||
- Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
|
|
||||||
- `PANEL_USERNAME`/`PANEL_PASSWORD_HASH` как env-переменные **не используются** для основного сценария — см. обновлённый раздел 8.
|
|
||||||
- **SASL-пароли приложений** — отдельная сущность (не путать с паролем администратора панели). Панель **генерирует** сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю **ровно один раз**, и НЕ хранит его в открытом виде для повторного показа (в `sasldb2` он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
|
|
||||||
2. **Валидация ввода на стороне сервера** (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, `.`, `-`, `@`). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: **каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется** (совпадение части после `@` с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит `smtpd_sender_login_maps` уже во время доставки.
|
|
||||||
3. **`postfix reload` и любые вызовы `os/exec`** — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (`exec.Command("postfix", "reload")`), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
|
|
||||||
4. **Запись в конфиг-файлы** — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
|
|
||||||
5. **Rate limiting** на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
|
|
||||||
6. **Сессии** — токен криптографически случайный, cookie с флагами `HttpOnly`, `Secure`, `SameSite`.
|
|
||||||
7. **Экранирование вывода** — данные из очереди/логов (тема письма, адреса) рендерятся через `html/template` с автоэкранированием (защита от XSS).
|
|
||||||
8. **Процесс панели не должен работать от root** (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Конфигурация (переменные окружения)
|
|
||||||
|
|
||||||
Минимальный набор env-переменных для настройки (исполнитель может расширить):
|
|
||||||
|
|
||||||
- `SELFPOST_HOSTNAME` — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, **не** отправляющий домен — домены добавляются через панель динамически.
|
|
||||||
- `DKIM_SELECTOR_DEFAULT` — селектор DKIM по умолчанию для новых доменов (например, `selfpost`)
|
|
||||||
- `TLS_CERT_FILE` — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
|
|
||||||
- `TLS_KEY_FILE` — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
|
|
||||||
- `SEND_LOG_RETENTION_DAYS` — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
|
|
||||||
- `RATE_LIMIT_MESSAGES_PER_IP` — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
|
|
||||||
- `RATE_LIMIT_WINDOW_SECONDS` — окно времени для базового лимита (`anvil_rate_time_unit`), по умолчанию 3600 (час)
|
|
||||||
|
|
||||||
**Администратор панели не задаётся через env-переменные.** Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через `docker inspect`, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.
|
|
||||||
|
|
||||||
Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже **не** в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Персистентность (bind mount на хосте)
|
|
||||||
|
|
||||||
Должны переживать перезапуск/пересоздание контейнера:
|
|
||||||
|
|
||||||
- **DKIM-ключи всех доменов** (`/etc/opendkim/keys` + `KeyTable`/`SigningTable`) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
|
|
||||||
- **SASL-база** (`sasldb2`) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
|
|
||||||
- **Конфиги Postfix**, изменяемые панелью: список доменов, карта привязки `smtpd_sender_login_maps` (логины приложений → домены) и т.п.
|
|
||||||
- **База состояния панели — SQLite** (единый файл, например `/data/selfpost.db`), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, **журнал отправки писем** (раздел 7.3) с retention-политикой, и **настройки дифференцированных лимитов отправки** — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
|
|
||||||
- **Очередь Postfix** (`/var/spool/postfix`) — чтобы недоставленные письма не терялись при рестарте.
|
|
||||||
|
|
||||||
**TLS-сертификаты в этот список не входят** — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).
|
|
||||||
|
|
||||||
**Ротация `mail.log` — обязательна.** В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (`SEND_LOG_RETENTION_DAYS`), сырой лог Postfix (`mail.log`, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить `logrotate` внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.
|
|
||||||
|
|
||||||
**Механизм — bind mount на хосте, не именованный Docker volume.** Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, `./data` рядом с `docker-compose.yml`, или зафиксированный абсолютный путь вроде `/opt/selfpost/data`) в консолидированный корень внутри контейнера (тот же `/data`, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через `docker volume inspect`/`docker cp`, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (`tar`, `rsync`, `scp`) без обращения к Docker вообще. `docker-compose.yml` должен использовать синтаксис bind mount (`./data:/data`), а не секцию `volumes:` с именованным томом.
|
|
||||||
|
|
||||||
**Оговорка про прямое копирование данных хостовым `tar` (в обход панели):** риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию **во время работы контейнера** — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования **остановлен**, наивный `tar` полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (`VACUUM INTO`/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой `tar` директории — безопасен, если сервис перед этим остановлен».
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Развёртывание
|
|
||||||
|
|
||||||
1. Поставка — `Dockerfile` + `docker-compose.yml` + документация.
|
|
||||||
2. **Reverse-proxy обязателен** и является единым источником TLS-сертификатов. Он:
|
|
||||||
- терминирует HTTPS для панели (HTTPS **не** реализуется в коде панели);
|
|
||||||
- выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
|
|
||||||
- поставляет PEM-файлы сертификата в контейнер SelfPost через **bind mount с хоста** (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
|
|
||||||
3. **Проект не привязан к конкретному reverse-proxy.** Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — **Apache**; остальные — как альтернативные фрагменты:
|
|
||||||
- **Apache (httpd)** — *основной сценарий, готовый `docker-compose.yml` из коробки.* Терминация HTTPS для панели через `mod_ssl` + `mod_proxy`/`mod_proxy_http`. Сертификаты — через `certbot` (Apache-плагин) либо встроенный `mod_md`. При использовании certbot PEM-файлы лежат готовыми в `/etc/letsencrypt/live/<hostname>/` на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для `mod_md` показать, как отдать сертификат в PEM в смонтированную директорию.
|
|
||||||
- **nginx** (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
|
|
||||||
- **Caddy** — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в `TLS_CERT_FILE`/`TLS_KEY_FILE`. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю **проверить актуальный путь хранения в текущей версии Caddy**.
|
|
||||||
- **Traefik** — сертификаты в `acme.json`, потребуется шаг извлечения PEM.
|
|
||||||
|
|
||||||
Для каждого варианта показать: связку в `docker-compose.yml` (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (`TLS_CERT_FILE`/`TLS_KEY_FILE`). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.
|
|
||||||
4. При использовании общего hostname для панели и почты это **один сертификат**, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.
|
|
||||||
5. **Рекомендуемый дефолт — Apache** (готовый `docker-compose.yml`, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.
|
|
||||||
6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности `cap_drop`, ограничение доступной ФС.
|
|
||||||
7. Документация должна включать раздел **«Требования к площадке»** — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».
|
|
||||||
8. Документация должна включать раздел **«Настройка DNS»**, явно разделяя записи уровня сервера и уровня домена:
|
|
||||||
- **Уровень сервера (один раз):** PTR/rDNS для IP сервера.
|
|
||||||
- **Уровень домена (для КАЖДОГО добавленного отправляющего домена):** SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
|
|
||||||
9. Документация должна включать раздел **«Прогрев IP»** — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).
|
|
||||||
10. **`docker-compose.yml` должен использовать фиксированный тег версии образа** (например, `selfpost:1.3.0`), не `:latest`. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.
|
|
||||||
11. Документация должна указывать **ориентировочные минимальные требования к машине**: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен `SEND_LOG_RETENTION_DAYS`) и ротируемого `mail.log` (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).
|
|
||||||
|
|
||||||
### 10.1. Сборка и публикация образа (CI)
|
|
||||||
|
|
||||||
Образ собирается автоматически в CI и публикуется в container registry как **неизменяемый артефакт под тегом версии**. Именно это делает реально работающими два уже принятых требования: проверку совместимости при восстановлении бэкапа (раздел 7.5.А) и фиксированный тег образа в `docker-compose.yml` (п. 10 выше). Если бы пользователь пересобирал образ локально из `Dockerfile`, две сборки «одной и той же версии» на разных машинах/в разное время не были бы идентичны (дрейф базового `debian:bookworm-slim` и версий apt-пакетов), и гарантия «восстанавливай в ту же версию» держалась бы на честном слове. Публикуемый по тегу immutable-образ снимает это: любой инстанс тянет побитово один и тот же образ.
|
|
||||||
|
|
||||||
**Механика:**
|
|
||||||
1. **Триггер — git-тег вида `vX.Y.Z`.** Push тега запускает CI-сборку. Обычные коммиты в ветку образ не публикуют.
|
|
||||||
2. **Версия выводится из тега в одном месте** и прокидывается в сборку двумя связанными способами: в бинарник панели через `-ldflags "-X main.version=X.Y.Z"` (раздел 11.1) и в тег самого образа. Это структурно гарантирует инвариант «тег образа == вшитая версия бинарника», на который опирается 7.5.А, — версия не задаётся вручную в двух местах, где легко разойтись.
|
|
||||||
3. **Публикация** — `<registry>/<owner>/selfpost:X.Y.Z`, тег иммутабельный. `:latest` для потребления в `docker-compose.yml` не используется (п. 10 выше).
|
|
||||||
|
|
||||||
**CI и registry — GitHub.** Workflow (GitHub Actions) лежит в самом репозитории (`.github/workflows/`), зеркалится на GitHub вместе с остальным кодом (раздел 11.7) и **выполняется там**; образ публикуется в **GitHub Container Registry (`ghcr.io`)**. Обоснование: для public-образов `ghcr` бесплатен и **без rate-limit на анонимный pull** — в отличие от Docker Hub, где анонимный pull лимитирован, а образ тянут на голом VPS при деплое, часто без `docker login`. Связка Actions + `ghcr` не добавляет внешних сервисов сверх уже используемого GitHub-зеркала.
|
|
||||||
|
|
||||||
**Альтернатива — `Quay.io` (задокументировать, но по умолчанию не реализовывать).** Нейтральный registry, тоже без лимитов на анонимный pull для public-образов, со встроенным сканированием уязвимостей, и не завязанный на конкретного CI-вендора (пушить в него можно из любого CI, включая тот же GitHub Actions). Зафиксирован как готовый путь отхода на случай, если в будущем захочется отвязать хранение образа от GitHub или уйти от моновендорности, — переезд не затрагивает остальной дизайн (меняется только адрес registry в workflow и в примере `docker-compose.yml`). На текущем этапе выбран `ghcr` ради минимума движущихся частей.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Deliverables (что должно быть на выходе)
|
|
||||||
|
|
||||||
1. `Dockerfile` — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (`go build -ldflags "-X main.version=..."`) и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
|
|
||||||
2. `supervisord.conf` — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным `priority=` и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
|
|
||||||
3. Конфигурационные шаблоны Postfix (включая SASL, `smtpd_sender_login_maps`, milter-цепочку `smtpd_milters` с OpenDKIM + journal-milter) и OpenDKIM (`KeyTable`/`SigningTable`).
|
|
||||||
4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
|
|
||||||
5. `docker-compose.yml` — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
|
|
||||||
6. **CLI-утилита резервного копирования** (например, `selfpost-backup`) внутри образа, вызываемая через `docker exec`, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
|
|
||||||
7. `README.md` — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, **процедура полного бэкапа/восстановления и процедура экспорта/импорта домена** (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: **Codeberg — основной, GitHub — зеркало** (зеркалирование кода настраивается push-зеркалированием средствами Codeberg; поверх зеркала на стороне GitHub работает CI-workflow сборки и публикации образа — см. раздел 10.1 и п. 10 ниже). README также указывает, откуда тянуть образ (`ghcr.io`), и что `docker-compose.yml` использует фиксированный тег версии (раздел 10, п. 10).
|
|
||||||
8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
|
|
||||||
9. **`LICENSE` — AGPL-3.0.** Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В `README.md` — явное упоминание лицензии и её смысла в двух-трёх предложениях.
|
|
||||||
10. **CI-workflow сборки и публикации образа (GitHub Actions)** — файл в `.github/workflows/`, запускаемый по git-тегу `vX.Y.Z`: собирает образ, прокидывает версию из тега в `-ldflags "-X main.version=..."` и в тег образа, публикует в `ghcr.io` как `ghcr.io/<owner>/selfpost:X.Y.Z` (раздел 10.1). Workflow зеркалится на GitHub и выполняется там. Публикация в `Quay.io` — как задокументированная альтернатива (раздел 10.1), не как основной путь.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Инструкции исполняющему агенту
|
|
||||||
|
|
||||||
1. **Не делать git-коммитов без явной инструкции в промпте.** Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
|
|
||||||
2. После написания Go-кода — обязательно выполнять `go build` и `go vet`; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — `go test`.
|
|
||||||
3. Проверять, что образ собирается (`docker build`) и контейнер стартует, прежде чем считать задачу выполненной.
|
|
||||||
4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
|
|
||||||
5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
|
|
||||||
6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
|
|
||||||
7. **Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ** и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
|
|
||||||
8. **Лицензии внешних Go-зависимостей** (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.
|
|
||||||
+136
-6
@@ -1,10 +1,40 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, and ship changes. Current sprint
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
state lives in [progress.md](progress.md) — read that first after `/clear`.
|
work for v1.x and 2.x lives in [roadmap.md](roadmap.md) (and, until the tag,
|
||||||
|
[v1.x-closure-plan.md](v1.x-closure-plan.md)). Product boundaries:
|
||||||
|
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
||||||
|
|
||||||
Product boundaries: [product.md](product.md). As-built layout:
|
---
|
||||||
|
|
||||||
|
## Resuming work
|
||||||
|
|
||||||
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
|
1. Read this file (process, docs rules, model routing).
|
||||||
|
2. Open [roadmap.md](roadmap.md) for open work; until `v1.0.0`, also
|
||||||
|
[v1.x-closure-plan.md](v1.x-closure-plan.md) for the remaining closure
|
||||||
|
checklist. Accepted risks — [security.md](security.md); as-built —
|
||||||
[architecture.md](architecture.md).
|
[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.
|
||||||
|
|
||||||
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
|
not duplicated here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model routing
|
||||||
|
|
||||||
|
| Kind of work | Model | Examples |
|
||||||
|
|---|---|---|
|
||||||
|
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
|
||||||
|
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
|
||||||
|
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
|
||||||
|
| Security **review** (not authorship) | **Fable** | pre-release checklist pass ([implementation-plan.md](implementation-plan.md) § D — done) |
|
||||||
|
|
||||||
|
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||||
|
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -100,7 +130,23 @@ Image and processes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Release build
|
## Commits and release build
|
||||||
|
|
||||||
|
Commit on every meaningful step (a working sub-feature, a green build, end of a
|
||||||
|
phase) — not every file save, and not only at phase end. Minimum: one commit per
|
||||||
|
closed phase, plus intermediate commits for coherent sub-steps. Branch `main`
|
||||||
|
unless a separate branch is requested. Push / PR only on explicit request.
|
||||||
|
|
||||||
|
Commit messages end with
|
||||||
|
`Co-Authored-By: Claude <model> <noreply@anthropic.com>` for the model that did
|
||||||
|
the step (e.g. `Claude Sonnet 4.6`).
|
||||||
|
|
||||||
|
Every such step also updates [CHANGELOG.md](../CHANGELOG.md) under
|
||||||
|
`[Unreleased]` (Keep a Changelog). On an explicit version cut, rename
|
||||||
|
`[Unreleased]` to `[X.Y.Z] - date` and open a fresh empty `[Unreleased]`. Image
|
||||||
|
tag / push only on explicit request (see `release.yml`).
|
||||||
|
|
||||||
|
### Release image
|
||||||
|
|
||||||
The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
||||||
`main`). The tag is the single source of version: it drives the image tag and
|
`main`). The tag is the single source of version: it drives the image tag and
|
||||||
@@ -113,13 +159,26 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
|||||||
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||||
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
4. Update the pinned tag in
|
4. Update the pinned tag in
|
||||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) (see
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml) in the **same**
|
||||||
[roadmap.md](roadmap.md) § «v1.x — documentation and deploy tail»).
|
commit as the tag (see [roadmap.md](roadmap.md) § «v1.x — documentation and
|
||||||
|
deploy tail»).
|
||||||
|
|
||||||
Ordinary commits **do not** publish an image.
|
Ordinary commits **do not** publish an image.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase closure
|
||||||
|
|
||||||
|
Before `/clear` at the end of a finished step:
|
||||||
|
|
||||||
|
1. Update [roadmap.md](roadmap.md) (and the active plan checklist, if any): what
|
||||||
|
changed, what is next.
|
||||||
|
2. Check the applicable «Done when…» criteria.
|
||||||
|
3. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]`.
|
||||||
|
4. Make the final commit for the step (when the user asks for a commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Static analysis and unit tests
|
### Static analysis and unit tests
|
||||||
@@ -199,3 +258,74 @@ emulation for e2e is impractical. E2e first, then push — the registry receives
|
|||||||
the bytes that passed the gate.
|
the bytes that passed the gate.
|
||||||
|
|
||||||
A failed e2e **blocks** image publication.
|
A failed e2e **blocks** image publication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
History of deleted plans lives in git and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
There is no `docs/archive/` directory.
|
||||||
|
|
||||||
|
### Documentation map
|
||||||
|
|
||||||
|
| Home | File |
|
||||||
|
|---|---|
|
||||||
|
| Operator install / quick start | [README.md](../README.md) |
|
||||||
|
| Operator guide | [guide.md](guide.md) |
|
||||||
|
| Product boundaries | [product.md](product.md) |
|
||||||
|
| 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 (v1.x tail, 2.x) | [roadmap.md](roadmap.md) |
|
||||||
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
|
`implementation-plan.md` remains only until the release cut (describes the
|
||||||
|
closed release gate); delete it in the release commit. Temporary
|
||||||
|
[v1.x-closure-plan.md](v1.x-closure-plan.md) goes away with that cut too.
|
||||||
|
|
||||||
|
### User-facing deliverables
|
||||||
|
|
||||||
|
| Artefact | Role |
|
||||||
|
|---|---|
|
||||||
|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||||||
|
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||||
|
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||||
|
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||||||
|
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||||
|
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||||
|
|
||||||
|
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
||||||
|
(candidates in [roadmap.md](roadmap.md)).
|
||||||
|
|
||||||
|
### Maintaining documentation
|
||||||
|
|
||||||
|
1. **Step rule:** a new or renamed env key, panel route, or observable mail-path
|
||||||
|
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
|
||||||
|
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
|
||||||
|
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
|
||||||
|
fails on an undocumented `loadConfig` or build-script key.
|
||||||
|
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||||
|
silent drive-by edits.
|
||||||
|
|
||||||
|
### Verifying docs against code
|
||||||
|
|
||||||
|
Every claim in the docs has a **source of truth in the tree**; verify from code
|
||||||
|
to prose.
|
||||||
|
|
||||||
|
| Claim class | Source of truth |
|
||||||
|
|---|---|
|
||||||
|
| Env keys and defaults | `loadConfig` — [cmd/panel/main.go](../cmd/panel/main.go); `${VAR:-…}` in [build/](../build/) |
|
||||||
|
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||||
|
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||||
|
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||||||
|
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
|
||||||
|
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||||||
|
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||||||
|
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||||
|
| Product / out of scope | [product.md](product.md) |
|
||||||
|
| As-built | [architecture.md](architecture.md) |
|
||||||
|
| Mandatory security | [security.md](security.md) |
|
||||||
|
|
||||||
|
Order: list what the code actually does → find it in [guide.md](guide.md) /
|
||||||
|
`architecture.md`. Before every tag, a short pass over this table — not a full
|
||||||
|
prose rewrite.
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# План документации SelfPost
|
|
||||||
|
|
||||||
**Статус: закрыт (D1–D9, август 2026).** Проход выполнен; история задач и
|
|
||||||
находок — в [CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот файл дальше
|
|
||||||
держит **состав пакета**, **метод сверки с кодом** и **правила**, чтобы
|
|
||||||
документация не разошлась снова.
|
|
||||||
|
|
||||||
**Живые документы (вместо архивного ТЗ):**
|
|
||||||
|
|
||||||
| Дом | Файл |
|
|
||||||
|---|---|
|
|
||||||
| Пользовательская поставка | [README.md](../README.md) + [guide.md](guide.md) |
|
|
||||||
| Границы продукта | [product.md](product.md) |
|
|
||||||
| As-built устройство | [architecture.md](architecture.md) |
|
|
||||||
| Процесс разработки | [development.md](development.md) |
|
|
||||||
| Безопасность | [security.md](security.md) |
|
|
||||||
| Исторический снимок v1.0 | [archive/specification-v1.0.md](archive/specification-v1.0.md) |
|
|
||||||
|
|
||||||
Отложенная полировка v1.x (тег образа в compose) — [roadmap.md](roadmap.md)
|
|
||||||
§ «v1.x — хвост документации и деплоя». Пункты про Quick start и `docs/logo`
|
|
||||||
закрыты.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Состав пакета
|
|
||||||
|
|
||||||
### Поставляемое пользователю
|
|
||||||
|
|
||||||
| Артефакт | Состояние |
|
|
||||||
|---|---|
|
|
||||||
| [README.md](../README.md) | Краткий обзор, требования, quick start, ссылки на документацию, reference deploy, лицензия |
|
|
||||||
| [guide.md](guide.md) | Прокси, env, DNS, прогрев IP, эксплуатация, rate limiting, бэкап, порты, тег образа |
|
|
||||||
| [LICENSE](../LICENSE) | AGPL-3.0, полный текст |
|
|
||||||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + прокси | Apache + nginx/Caddy/Traefik в [deploy/](../deploy/) |
|
|
||||||
| [deploy/.env.example](../deploy/.env.example) | Публичные переменные; полный справочник в [guide.md](guide.md) |
|
|
||||||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
|
||||||
|
|
||||||
### Рабочие документы
|
|
||||||
|
|
||||||
[progress.md](progress.md), [implementation-plan.md](implementation-plan.md),
|
|
||||||
[roadmap.md](roadmap.md), этот файл.
|
|
||||||
|
|
||||||
**Вне объёма v1.x:** `CONTRIBUTING.md`, man-страницы, отдельный сайт документации.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Метод сверки с кодом
|
|
||||||
|
|
||||||
Правило: у каждого утверждения в документации есть **источник истины в дереве**;
|
|
||||||
сверка идёт от кода к тексту.
|
|
||||||
|
|
||||||
| Класс утверждений | Источник истины |
|
|
||||||
|---|---|
|
|
||||||
| Env-переменные, дефолты | `loadConfig` — [cmd/panel/main.go](../cmd/panel/main.go); `${VAR:-…}` в [build/](../build/) |
|
|
||||||
| Почтовый тракт | [build/postfix-config.sh](../build/postfix-config.sh) |
|
|
||||||
| Маршруты панели | [internal/web/web.go](../internal/web/web.go) |
|
|
||||||
| Бэкап/restore, экспорт домена | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
|
||||||
| Сессии | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
|
|
||||||
| Ротация лога, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
|
||||||
| Деплой | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
|
||||||
| Чеклист README | таблица «Поставляемое пользователю» выше; детали — [guide.md](guide.md) |
|
|
||||||
| Продукт, out of scope | [product.md](product.md) |
|
|
||||||
| As-built | [architecture.md](architecture.md) |
|
|
||||||
| Обязательная безопасность | [security.md](security.md) |
|
|
||||||
|
|
||||||
Порядок: перечислить фактическое в коде → найти в [guide.md](guide.md) / `architecture.md`.
|
|
||||||
Перед каждым тегом — короткий проход по этой таблице, не полная ревизия текста.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Правила поддержки
|
|
||||||
|
|
||||||
1. **Правило шага:** новая/переименованная env-переменная, маршрут панели или
|
|
||||||
наблюдаемое поведение почтового тракта закрываются вместе с [guide.md](guide.md) /
|
|
||||||
`.env.example` и записью в CHANGELOG (протокол — [progress.md](progress.md)).
|
|
||||||
2. **Регресс env (D7):** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go) —
|
|
||||||
падает на недокументированном ключе `loadConfig` или build-скриптов.
|
|
||||||
3. **Новые расхождения** дописываются в [roadmap.md](roadmap.md) или
|
|
||||||
[implementation-plan.md](implementation-plan.md), а не исправляются молча.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Гейт релиза (документация)
|
|
||||||
|
|
||||||
Документационный проход **D1–D9 закрыт.** До тега релиза остаётся общий гейт:
|
|
||||||
e2e (готов, [development.md](development.md)) и ревизия безопасности
|
|
||||||
([implementation-plan.md](implementation-plan.md) § D) — см. [progress.md](progress.md).
|
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md),
|
B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md),
|
||||||
e2e/CI в [development.md](development.md), принятые риски в
|
e2e/CI в [development.md](development.md), принятые риски в
|
||||||
[security.md](security.md). Текущее состояние и следующий шаг:
|
[security.md](security.md). Текущее состояние и следующий шаг:
|
||||||
[progress.md](progress.md). Объём 2.x.x — [roadmap.md](roadmap.md).
|
[roadmap.md](roadmap.md) и [v1.x-closure-plan.md](v1.x-closure-plan.md).
|
||||||
|
Объём 2.x.x — [roadmap.md](roadmap.md).
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0.
|
**Основа:** [product.md](product.md) v1.0.
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
# Прогресс реализации SelfPost
|
|
||||||
|
|
||||||
Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы.
|
|
||||||
План (открытые вопросы для v1.0/v1.x): [implementation-plan.md](implementation-plan.md).
|
|
||||||
Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md).
|
|
||||||
Продукт: [product.md](product.md), устройство: [architecture.md](architecture.md).
|
|
||||||
Процесс разработки: [development.md](development.md). Принятые риски безопасности: [security.md](security.md).
|
|
||||||
История релизов: [CHANGELOG.md](../CHANGELOG.md).
|
|
||||||
История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется.
|
|
||||||
|
|
||||||
## Как возобновить после сброса контекста
|
|
||||||
|
|
||||||
1. Прочитать этот файл (текущее состояние, что дальше).
|
|
||||||
2. Открыть `implementation-plan.md` — там остаётся предрелизная ревизия безопасности (§ D); линия 2.x.x — в `roadmap.md`; принятые риски — в `security.md`; as-built B.1–C.4 — в `architecture.md` и `development.md`.
|
|
||||||
3. При необходимости — [architecture.md](architecture.md) и [product.md](product.md).
|
|
||||||
4. Продолжить с пункта «Следующий шаг».
|
|
||||||
|
|
||||||
## Модель по типу работы
|
|
||||||
|
|
||||||
Правило: безопасность / инфра / риск-критичное → **Opus**; UI / документация / бойлерплейт → **Sonnet**; тривиальная механика → **Haiku**.
|
|
||||||
|
|
||||||
Исключение — **ревизия** (не написание) кода: предрелизная проверка на уязвимости
|
|
||||||
([implementation-plan.md](implementation-plan.md) § D) делается моделью **Fable**,
|
|
||||||
чтобы проверял не тот, кто писал.
|
|
||||||
|
|
||||||
## Коммиты
|
|
||||||
|
|
||||||
Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude <модель> <noreply@anthropic.com>` — с той моделью, которая этот шаг делала (на момент Фазы 14 — `Claude Opus 5`).
|
|
||||||
|
|
||||||
На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml).
|
|
||||||
|
|
||||||
## Протокол закрытия фазы/крупного шага
|
|
||||||
|
|
||||||
Перед `/clear` в конце каждого законченного шага Claude:
|
|
||||||
1. Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше.
|
|
||||||
2. Проверяет применимые критерии «Готово, когда…».
|
|
||||||
3. Дописывает `CHANGELOG.md` под `[Unreleased]`.
|
|
||||||
4. Делает финальный коммит шага.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Текущее состояние
|
|
||||||
|
|
||||||
- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
|
|
||||||
- **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые.
|
|
||||||
- **B.2 реализован** (не выкачен на прод): ротация `mail.log` ушла с `copytruncate` на «переименовать + `postfix reload`» — `build/logrotate-mail.conf` (`nocreate` заменён на `create 0644 root root` **не по плану, а по стендовой проверке**: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом `0600`, недоступным непривилегированной панели, — `create` в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); `follow()` в `internal/logtail/logtail.go` при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; `readLogTail()` в `internal/web/handlers_monitor.go` считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на стенде; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится).
|
|
||||||
- **B.3 реализован** (не выкачен на прод): `build/entrypoint.sh` проверяет `SELFPOST_HOSTNAME` до `postfix-config.sh` и до `supervisord` — при пустом значении `exit 1` с развёрнутым текстом ошибки (что это за имя, почему обязательно, пример, где задаётся); плюс синтаксическая проверка через `case`: минимум одна точка, без схемы/порта/пробелов (`*://*`, `*:*`, пробел/таб — тот же класс тихого спам-отказа, что и пустое значение). `saslRealm()` и fallback в `postfix-config.sh` не тронуты — после гейта эти ветки мертвы. Заодно отмечена обязательность переменной в `README.md` и `deploy/.env.example`. Проверено на стенде (отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые.
|
|
||||||
- **C.4 реализован** (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` (свой `go.mod`, не подхватывается `go test ./...` основного модуля) поверх поставляемого `deploy/docker-compose.yml` плюс `test/e2e/compose.override.yml` (самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, `SELFPOST_HOSTNAME=mail.e2e.test`, высокие порты `20465/20587/20080`, изолированный compose-проект `selfpost-e2e`, свой `--project-directory` — прод на том же хосте не задет). Герметичная почта: CoreDNS (`test/e2e/dns/Corefile` — авторитетна только для `e2e.test`, `file`-плагин с саб-директивой `reload` перечитывает `db.zone` по mtime, без сигналов) плюс `smtp-sink` из пакета postfix (`test/e2e/sink/`) как sink-MX. Сценарий (`test/e2e/*_test.go`): старт контейнера → все supervisord-программы `RUNNING` (`postfix-reload` — `STOPPED`) → токен из `/data/setup-token` → setup → login → добавление домена → DKIM-запись **скраплена со страницы панели** и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (`go-msgauth/dkim` с кастомным `LookupTXT` через CoreDNS) против ключа **из DNS**, не из панели напрямую → send-log `queued → sent`. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (`reject_sender_login_mismatch` репортится Postfix'ом на RCPT, не MAIL — `smtpd_delay_reject=yes` по умолчанию), L1-лимит (anvil, override `RATE_LIMIT_MESSAGES_PER_IP=50` — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → `rejected`-строка в send-log), fail-open journal-milter'а (`supervisorctl stop panel`, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный `SELFPOST_HOSTNAME` (отдельный один-разовый контейнер, не общий стенд), сессия переживает `docker restart` (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). `make e2e` — локальный прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `+` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на стенде**: `make e2e` — зелёный (`go vet`/`gofmt -l` тоже чистые в обоих модулях). `release.yml` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge` — `docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути.
|
|
||||||
- **Документация:** план D1–D9 закрыт ([documentation-plan.md](documentation-plan.md) — только метод и правила поддержки). Хвост v1.x — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя»; из него остался только бамп тега образа (Quick start и `docs/logo` закрыты).
|
|
||||||
- **Рецензирование кодовой базы** (2026-08-05, `522425a`): 10 разделов (архитектура, качество, docs, GUI, legacy, риски) плюс приоритизированный план доработок фазами 0–3. Критичных багов не найдено; единственным блокером релиза названа § D. **План выполнен целиком** (см. записи ниже), поэтому сам документ `docs/code-review.md` удалён — незакрытые пункты унесены в [roadmap.md](roadmap.md) (разбиение `internal/web`, индекс документации в README, адаптивный интервал опроса, `CONTRIBUTING.md`), остальное либо сделано, либо уже описано в architecture.md / security.md / комментариях кода. Текст ревизии — в git-истории.
|
|
||||||
- **§ D выполнен (2026-08-06):** предрелизная ревизия безопасности моделью Fable — диф от аудита v1.0 (Фаза 11, `bd64e80`) до HEAD + полный проход по чек-листу [security.md](security.md) (бывшее ТЗ 7.6). Эксплуатируемых находок нет; одна правка defence-in-depth (`--` перед логином в argv `saslpasswd2`, `internal/app/sasl.go` + тест). Принятые риски не пополнились. Детали — [implementation-plan.md](implementation-plan.md) § D и CHANGELOG `[Unreleased]/Security`. Локально `go vet`/`go test ./internal/app/...` чистые; падения `internal/domain` (`TestWriteLoadPrivateKeyRoundtrip`, `TestRenderTables`) и `internal/logtail` (`TestFollowTailsAndRotates`) — Windows-специфика (права файлов/`\` в путях/rename открытого файла), на Linux CI зелено.
|
|
||||||
- **Фаза 1 плана ревизии выполнена (2026-08-06)** (doc/code hygiene, P1): cleanup ~30 stale «Phase N» комментариев в коде и shell-скриптах; исправлен stale-комментарий в `handlers_domains.go`; ADR CSRF (Origin vs токены) добавлен в [security.md](security.md); known-limitations по log-tailer уже был в [architecture.md](architecture.md) § Log tailer — отдельного действия не потребовалось; `docs/logo` в [roadmap.md](roadmap.md) закрыт (каталога нет, критерию соответствует); `gofmt -l` добавлен в CI (`.github/workflows/test.yml`). `gofmt`/`go vet`/`go test ./...` чистые в обоих модулях.
|
|
||||||
- **Фаза 1.5 плана ревизии выполнена (2026-08-06)** (шифрование резервных копий, P1): новый пакет `internal/secretfile` — конверт `magic SELFPOST1 | type | scrypt-параметры | salt | nonce-prefix` + поток 64 KiB чанков AES-256-GCM, каждый с AAD `header+counter+last`, поэтому обрезка, перестановка и подмена не открываются (стриминг в обе стороны — полный бэкап не держится в памяти). Панель: чекбокс «Encrypt with a password» в форме полного бэкапа и экспорта домена (общий партиал `templates/encrypt_fields.html`, показ/очистка полей — `panel.js`, без inline-скриптов), импорт домена принимает `.spde` (шифрование определяется по magic, не по расширению) с полем пароля. CLI `selfpost-backup`: пишет `.spbk` при заданном пароле и умеет `-decrypt` (иначе зашифрованный бэкап нечем распаковать при restore); пароль — только `SELFPOST_BACKUP_PASSWORD` / `-password-file`, никогда argv. Умолчание не изменилось: галочка снята — прежние `.tar.gz` / `.json` байт в байт. Тесты: round-trip по размерам (0, границы чанка, несколько чанков), неверный пароль, обрезка, перестановка чанков, порча байта, чужие KDF-параметры; валидация формы пароля; round-trip CLI create→decrypt→tar. Docs: README § *Encrypting a backup or export*, [security.md](security.md) § «Резервная копия и экспорт домена» + принятый риск (шифрование опционально), [architecture.md](architecture.md) § Persistence. `gofmt`/`go vet`/`go test ./...` чистые (кроме известных Windows-падений `internal/domain`, `internal/logtail`). E2E-сценарий не добавлялся: в `test/e2e/` бэкапа не было и раньше, а прогнать новый тест локально нечем (нет Docker) — кандидат при следующем прогоне на стенде.
|
|
||||||
- **Фаза 2 плана ревизии выполнена (2026-08-06)** (GUI polish, P2): опрос мониторинговых страниц не уходит на сервер, пока вкладка скрыта — фильтр повешен на `htmx:beforeRequest` в `panel.js`, а не на встроенный в htmx фильтр триггера (тот вычисляется через `new Function`, что CSP панели `default-src 'self'` без `unsafe-eval` молча ломает); тёмная тема переписана с каскада `!important` на переопределение CSS-переменных в одном блоке `prefers-color-scheme: dark`; дублирующее правило `main { max-width }` сведено к одному базовому плюс задокументированные постраничные оверрайды. Только CSS/JS, поведения сервера не касается; вживую не проверялось (нет Docker локально) — кандидат на следующий прогон на стенде.
|
|
||||||
- **Фаза 3 плана ревизии выполнена (2026-08-06)** (operational improvements, P2–P3): (1) log-tailer сохраняет позицию чтения — таблица `logtail_state` (миграция `0003`, `internal/store/logtail.go`) хранит offset + отпечаток первых 512 байт лога, `internal/logtail/offset.go` решает откуда стартовать: отпечаток совпал → продолжаем с offset (дочитывается хвост, написанный пока панель лежала); не совпал (лог сменился/пересоздан) → читаем файл с начала (повторный разбор безвреден, `UpdateStatus` идемпотентен); записи нет вовсе (первый запуск) → с конца, как раньше. Запись offset — не чаще раза в 5 с, плюс форс при ротации и на выключении; сохраняется позиция *потреблённых* байт (минус недочитанная частичная строка). (2) L2-лимит перестал промахиваться при параллельных сессиях: между проверкой на MAIL FROM и вставкой строки на end-of-message сообщение не видно в БД, поэтому N одновременных сессий пропускали друг друга — теперь к счёту из БД добавляются «в полёте» (`internal/milter/inflight.go`, общий на процесс реестр резерваций); резервация освобождается после записи в send-log, на ABORT и по TTL 10 минут (у go-milter нет колбэка на закрытие соединения, а вечная резервация — это fail-closed-дрейф, которого у лимитера быть не должно). Транзакция «count+insert», как предлагал review, невозможна буквально: эти два шага разнесены по разным стадиям SMTP-транзакции. Тесты: restart/rotation-resume для tailer'а, четыре сценария резерваций для лимита. `gofmt`/`go vet` чистые; `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). Не проверено на стенде (нет Docker локально) — кандидат на следующий прогон на стенде.
|
|
||||||
- **Добор по плану ревизии выполнен (2026-08-06):** (1) проект переехал на единственную площадку — GitHub (Codeberg уходит): вместе с URL, лицензионными шапками SVG/HTML и docs переехал путь Go-модуля на `github.com/mixeme/selfpost` (`go.mod`, `test/e2e/go.mod`, все импорты, `MODULE` в Makefile, `-ldflags` в Dockerfile и development.md) — оставлять импорты на исчезающем хосте нельзя, `go get`/`go install` сломались бы; (2) ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», как просило ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) [architecture.md](architecture.md) § Code layers — диаграмма слоёв (A2); (4) `TestParseDelivery` расширен экзотикой mail.log — и **вскрыл реальный баг**: шаблон брал `status=` жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с `status=sent` в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) `CONTRIBUTING.md` перенесён в 2.x, бамп тега образа и git-тег оставлены в [roadmap.md](roadmap.md) § v1.x. `gofmt`/`go vet` чистые в обоих модулях, `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). На стенде не проверялось (нет Docker локально).
|
|
||||||
- **v1.x-closure Фаза 1 выполнена (2026-08-08)** (адаптивный опрос мониторинга): четыре HTMX-фрагмента (`status_body`, `mail_queue_body`, `system_log_body`, `deliveries_rows`) несут `data-poll` и `hx-trigger="load"` только для первого запроса; `panel.js` планирует следующий опрос после `htmx:afterSwap` / `htmx:responseError` — 5 s при активности оператора на странице, 30 s при видимой, но простаивающей вкладке, 0 при скрытой (`beforeRequest` + сброс таймеров на `visibilitychange`). Без `hx-trigger="every … [expr]"` (CSP / `unsafe-eval`). Docs: `architecture.md`, `roadmap.md`, CHANGELOG. На стенде не проверялось.
|
|
||||||
- **v1.x-closure Фаза 2 выполнена (2026-08-08)** (send-log vs `mail.log`): (1) лог переехал из эфемерного `/var/log` в `/data/log/mail.log` — `maillog_file` в `postfix-config.sh` и `MAIL_LOG` в `cmd/panel/main.go` берут один и тот же дефолт, `entrypoint.sh` создаёт каталог `2750 postfix:selfpost` и нормализует файлы в `0640` на каждом старте (пишет `postlogd` от `postfix`, читает панель по общей группе; postlogd сам создал бы файл в `0600`, поэтому создаём его мы, а logrotate — `create 0640 postfix selfpost`), каталог исключён из общего `chown … panel` в начале entrypoint и из архива бэкапа (`internal/backup`, диагностика, а не состояние). (2) Строки, чьи delivery-строки потеряны безвозвратно, больше не висят `queued` вечно: `postfix.QueueIDs` разбирает `postqueue -p`, `store.ListQueuedOlderThan` отдаёт кандидатов, `internal/logtail` раз в 5 минут закрывает как `bounced` те, чьего queue-id в очереди уже нет (grace 2 мин). Три предохранителя: sweep стартует только после того, как tailer впервые дочитал лог до конца (на рестарте ответ лежит в самом логе), grace покрывает письмо «в полёте», нечитаемый `postqueue` не трогает ничего. Ложно-отрицательный `bounced` — новый принятый риск в [security.md](security.md). Тесты: парсер очереди (включая строку-причину deferred и «Mail queue is empty»), `ListQueuedOlderThan`, три сценария sweep, исключение `log/` из бэкапа. `gofmt`/`go vet` чистые в обоих модулях, `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). **На стенде не проверялось (нет Docker локально): образ не собирался, контейнер не стартовал** — права на `/data/log`, чтение лога панелью, прокрутка logrotate и `postqueue -p` из-под `panel` подлежат проверке при выкате.
|
|
||||||
- **Дальше:** v1.x-closure [v1.x-closure-plan.md](v1.x-closure-plan.md) — **Фазы 1–2 закрыты**. Следующий шаг — **Фаза 3** (docs: development.md, README, удаление планов и `docs/archive/`). Релизный гейт по коду закрыт; бамп тега образа и git tag — по явной команде (Фазы 4–5).
|
|
||||||
- **Принятые риски** — [security.md](security.md). **Опционально v1.x / 2.x** — [roadmap.md](roadmap.md) (хвост документации, send-log gaps, Фаза O1+, роль администратора домена).
|
|
||||||
- **Прод:** инстанс с реальным Let's Encrypt сертификатом и живым deliverability (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
|
|
||||||
|
|
||||||
## Рабочая петля (dev loop) — ВАЖНО
|
|
||||||
|
|
||||||
Сборка, unit-тесты и e2e требуют Go 1.26+ и Docker + Compose v2 — см.
|
|
||||||
[development.md](development.md). Конкретная связка машин (всё локально,
|
|
||||||
отдельный сервер, только CI) у каждого разработчика своя; источник истины —
|
|
||||||
git-репозиторий.
|
|
||||||
+32
-34
@@ -10,17 +10,20 @@
|
|||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
||||||
решением.
|
решением.
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Несделанное для v1.0/v1.x
|
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
||||||
— в [implementation-plan.md](implementation-plan.md). Хвост закрытого
|
[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) — в секции ниже.
|
документационного прохода (D1–D9) — в секции ниже.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## v1.x — хвост документации и деплоя
|
||||||
|
|
||||||
**Статус:** не блокирует релизный тег; перенесено из закрытого
|
**Статус:** не блокирует релизный тег; бывший хвост закрытого
|
||||||
[documentation-plan.md](documentation-plan.md) (бывшая находка 11 и отложенный
|
документационного прохода (D1–D9). Делать по желанию или в релизном коммите,
|
||||||
пункт D4). Делать по желанию или в релизном коммите, где указано.
|
где указано. Сводка чек-листов до тега —
|
||||||
|
[v1.x-closure-plan.md](v1.x-closure-plan.md).
|
||||||
|
|
||||||
**Тег образа в compose + git tag — один релизный коммит (R1).** В
|
**Тег образа в compose + git tag — один релизный коммит (R1).** В
|
||||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить
|
||||||
@@ -28,36 +31,33 @@
|
|||||||
Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает
|
Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает
|
||||||
только до первого выката по тегу. Сам тег — последний шаг релизного гейта:
|
только до первого выката по тегу. Сам тег — последний шаг релизного гейта:
|
||||||
содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде
|
содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде
|
||||||
оператора ([progress.md](progress.md)). После тега `release.yml`
|
оператора ([development.md](development.md) § Commits and release build). После
|
||||||
собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`, поэтому compose с новым
|
тега `release.yml` собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`,
|
||||||
тегом и сам тег обязаны появиться вместе — иначе compose неделю ссылается на
|
поэтому compose с новым тегом и сам тег обязаны появиться вместе — иначе
|
||||||
несуществующий образ.
|
compose неделю ссылается на несуществующий образ.
|
||||||
|
|
||||||
**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт:
|
**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт:
|
||||||
уникального содержания в нём нет, § D (предрелизная ревизия безопасности)
|
уникального содержания в нём нет, § D (предрелизная ревизия безопасности)
|
||||||
продублирован в [progress.md](progress.md), [security.md](security.md) и
|
продублирован в [security.md](security.md) и CHANGELOG `[Unreleased]/Security`,
|
||||||
CHANGELOG `[Unreleased]/Security`, а разделы B.1–B.3 и C.4 вырезаны ещё в
|
а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только
|
||||||
`22f86d1`. Держится до тега только потому, что описывает релизный гейт, пока тот
|
потому, что описывает релизный гейт, пока тот формально не закрыт. При резке
|
||||||
формально не закрыт. При резке версии:
|
версии:
|
||||||
|
|
||||||
1. Переместить в `docs/archive/` (рядом со `specification-v1.0.md`) — история
|
1. Удалить файл (история § D — в git и CHANGELOG; `docs/archive/` не храним).
|
||||||
§ D сохраняется, из активной документации уходит.
|
|
||||||
2. Перецелить ссылки из кода и CI ([Makefile](../Makefile),
|
2. Перецелить ссылки из кода и CI ([Makefile](../Makefile),
|
||||||
[.github/workflows/release.yml](../.github/workflows/release.yml),
|
[.github/workflows/release.yml](../.github/workflows/release.yml),
|
||||||
[test/e2e/main_test.go](../test/e2e/main_test.go)) — они ссылаются на «план
|
[test/e2e/main_test.go](../test/e2e/main_test.go)) — они ссылаются на «план
|
||||||
C.4», секцию, которой в файле уже нет; актуальное описание e2e — в
|
C.4», секцию, которой в файле уже нет; актуальное описание e2e — в
|
||||||
[development.md](development.md).
|
[development.md](development.md).
|
||||||
3. Перецелить ссылки из документации: [README.md](../README.md) («Open v1.x
|
3. Перецелить оставшиеся ссылки из документации на
|
||||||
questions» — открытых вопросов там нет) → [progress.md](progress.md);
|
[development.md](development.md) / [security.md](security.md) /
|
||||||
[security.md](security.md), [documentation-plan.md](documentation-plan.md),
|
[roadmap.md](roadmap.md).
|
||||||
[progress.md](progress.md) и шапку этого файла → на
|
4. Удалить [v1.x-closure-plan.md](v1.x-closure-plan.md) в том же или следующем
|
||||||
`progress.md`/`security.md`.
|
коммите.
|
||||||
4. В [progress.md](progress.md) убрать шаг «Открыть `implementation-plan.md`» —
|
|
||||||
он выполнен.
|
|
||||||
|
|
||||||
**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит
|
**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит
|
||||||
git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок на него в
|
git-тег `vX.Y.Z`; `implementation-plan.md` и `v1.x-closure-plan.md` удалены,
|
||||||
активных документах и в коде/CI не осталось.
|
ссылок на них в активных документах и в коде/CI не осталось.
|
||||||
|
|
||||||
(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо
|
(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо
|
||||||
содержит файлы, либо отсутствует» удовлетворяет; Quick start в
|
содержит файлы, либо отсутствует» удовлетворяет; Quick start в
|
||||||
@@ -65,11 +65,9 @@ git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок
|
|||||||
`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал
|
`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал
|
||||||
больше нет.)
|
больше нет.)
|
||||||
|
|
||||||
**Сводный индекс документации в README.** Ссылки на `docs/` разбросаны по
|
**Сводный индекс документации в README.** ~~Ссылки на `docs/` разбросаны по
|
||||||
тексту README (блок в шапке плюс упоминания по месту), единого списка нет —
|
тексту README…~~ **Закрыто (v1.x-closure Фаза 3):** секция Documentation в
|
||||||
читателю, который ищет «а где вообще что», приходится вычитывать документ.
|
[README.md](../README.md) — единый список operator docs + roadmap.
|
||||||
Стоит одного абзаца со списком всех файлов `docs/` и одной строкой на каждый.
|
|
||||||
Мелочь, но именно она делает набор документов набором, а не россыпью.
|
|
||||||
|
|
||||||
**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не
|
**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не
|
||||||
опрашивает сервер (фильтр на `htmx:beforeRequest` в
|
опрашивает сервер (фильтр на `htmx:beforeRequest` в
|
||||||
@@ -154,17 +152,17 @@ git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок
|
|||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
||||||
моделей по типу работы, протокол коммитов, требование
|
моделей по типу работы, протокол коммитов, требование
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть, но в
|
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
||||||
[development.md](development.md) и [progress.md](progress.md) — то есть на
|
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
||||||
русском и вперемешку с внутренним состоянием проекта.
|
работа, русский).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
проект вовне: английская документация процесса ([development.md](development.md),
|
||||||
README, `architecture.md`; `progress.md`, `roadmap.md` — внутренние, на русском)
|
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
||||||
и первый внешний интерес после публикации релиза.
|
русском) и первый внешний интерес после публикации релиза.
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
||||||
|
|||||||
+23
-23
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
- [x] **Фаза 1** — адаптивный опрос мониторинга
|
- [x] **Фаза 1** — адаптивный опрос мониторинга
|
||||||
- [x] **Фаза 2** — mail.log в `/data` + postqueue reconcile
|
- [x] **Фаза 2** — mail.log в `/data` + postqueue reconcile
|
||||||
- [ ] **Фаза 3** — docs: development.md, README, удаление планов и `docs/archive/`
|
- [x] **Фаза 3** — docs: development.md, README, удаление планов и `docs/archive/`
|
||||||
- [ ] **Фаза 4** — релизный коммит `1.0.0` (по явной команде)
|
- [ ] **Фаза 4** — релизный коммит `1.0.0` (по явной команде)
|
||||||
- [ ] **Фаза 5** — tag `v1.0.0` + push (по явной команде)
|
- [ ] **Фаза 5** — tag `v1.0.0` + push (по явной команде)
|
||||||
- [ ] **Фаза 6** — выкат на прод (оператор)
|
- [ ] **Фаза 6** — выкат на прод (оператор)
|
||||||
@@ -114,40 +114,40 @@
|
|||||||
|
|
||||||
### 3.1 `documentation-plan.md` → development.md, затем delete
|
### 3.1 `documentation-plan.md` → development.md, затем delete
|
||||||
|
|
||||||
- [ ] development.md § **Documentation**:
|
- [x] development.md § **Documentation**:
|
||||||
- [ ] Documentation map (без archive; история = git + CHANGELOG)
|
- [x] Documentation map (без archive; история = git + CHANGELOG)
|
||||||
- [ ] User-facing deliverables
|
- [x] User-facing deliverables
|
||||||
- [ ] Maintaining documentation (§3 правила)
|
- [x] Maintaining documentation (§3 правила)
|
||||||
- [ ] Verifying docs against code (полная таблица §2)
|
- [x] Verifying docs against code (полная таблица §2)
|
||||||
- [ ] `architecture.md` шапка → development.md
|
- [x] `architecture.md` шапка → development.md
|
||||||
- [ ] Удалить `documentation-plan.md`
|
- [x] Удалить `documentation-plan.md`
|
||||||
- [ ] Retarget `roadmap.md`
|
- [x] Retarget `roadmap.md`
|
||||||
|
|
||||||
### 3.2 `progress.md` → development.md, затем delete
|
### 3.2 `progress.md` → development.md, затем delete
|
||||||
|
|
||||||
- [ ] development.md § **Resuming work**
|
- [x] development.md § **Resuming work**
|
||||||
- [ ] development.md § **Model routing**
|
- [x] development.md § **Model routing**
|
||||||
- [ ] development.md § **Commits** (слить с Release build)
|
- [x] development.md § **Commits** (слить с Release build)
|
||||||
- [ ] development.md § **Phase closure** (roadmap, не progress)
|
- [x] development.md § **Phase closure** (roadmap, не progress)
|
||||||
- [ ] `agent-rules.mdc` → development.md
|
- [x] `agent-rules.mdc` → development.md
|
||||||
- [ ] Удалить `progress.md`
|
- [x] Удалить `progress.md`
|
||||||
- [ ] Retarget все ссылки
|
- [x] Retarget все ссылки
|
||||||
|
|
||||||
### 3.3 Удалить `docs/archive/`
|
### 3.3 Удалить `docs/archive/`
|
||||||
|
|
||||||
- [ ] Удалить `docs/archive/specification-v1.0.md`
|
- [x] Удалить `docs/archive/specification-v1.0.md`
|
||||||
- [ ] Удалить каталог `docs/archive/`
|
- [x] Удалить каталог `docs/archive/`
|
||||||
- [ ] Убрать ссылки на archive из живых docs (CHANGELOG историю ниже 0.6.0 не трогать)
|
- [x] Убрать ссылки на archive из живых docs (CHANGELOG историю ниже 0.6.0 не трогать)
|
||||||
|
|
||||||
### 3.4 README
|
### 3.4 README
|
||||||
|
|
||||||
- [ ] § Documentation: operator docs + roadmap (internal, Russian)
|
- [x] § Documentation: operator docs + roadmap (internal, Russian)
|
||||||
- [ ] Без секции Archive
|
- [x] Без секции Archive
|
||||||
|
|
||||||
### 3.5 Проверка
|
### 3.5 Проверка
|
||||||
|
|
||||||
- [ ] `gofmt` / `vet` / `test`
|
- [x] `gofmt` / `vet` / `test`
|
||||||
- [ ] grep живых ссылок на удалённые файлы
|
- [x] grep живых ссылок на удалённые файлы
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user