diff --git a/CHANGELOG.md b/CHANGELOG.md index ab03cc7..315ecb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- docs: `documentation-plan.md` marked closed (D1–D9); trimmed to package + checklist, code-verification method, and ongoing maintenance rules. +- docs: `roadmap.md` — v1.x doc/deploy tail (Codeberg Quick start, compose + image tag at release, `docs/logo`); archived-spec references replaced with + `product.md` / `security.md` / `development.md`. +- docs: `progress.md` — documentation pass closed; deferred polish in roadmap. - `/healthz` now checks supervisord mail-path processes, not HTTP alone. - `build/Dockerfile`: `curl` for `HEALTHCHECK`; probe on port 8080. - docs (D3): README backup — stopped-container `tar` of `./data` (with live-container diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md index fb1153b..2081998 100644 --- a/docs/documentation-plan.md +++ b/docs/documentation-plan.md @@ -1,328 +1,85 @@ # План документации SelfPost -**Зачем этот файл.** Документация — часть поставки (deliverables v1.0, пп. 5, 7, 9 -в [archive/specification-v1.0.md](archive/specification-v1.0.md)), а не сопроводительный текст. Перед тегом -релиза она обязана описывать **то, что делает код**, а не то, что задумывалось: -расхождение здесь — такой же дефект, как несоответствие требованиям, только -обнаруживает его пользователь на своём проде. План описывает, из чего состоит -пакет, как он сверяется с кодом, что уже разошлось (первый проход выполнен, -результаты ниже) и что с этим делать. +**Статус: закрыт (D1–D9, август 2026).** Проход выполнен; история задач и +находок — в [CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот файл дальше +держит **состав пакета**, **метод сверки с кодом** и **правила**, чтобы +документация не разошлась снова. -**Цель (достигнута в D9):** ТЗ v1.0 **выведено из обращения** — v1.0 -реализован и каждый блок ТЗ получил постоянный дом: пользовательское — в -README, устройство — в `architecture.md`, продуктовые границы — в -`product.md`, обязательная безопасность — в `security.md`, процесс разработки — -в `development.md`. Архив: [archive/specification-v1.0.md](archive/specification-v1.0.md). +**Живые документы (вместо архивного ТЗ):** -**Место в общем плане:** документационный проход идёт вместе с **D.5** -([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза, -после B.1–B.3 и C.4, потому что именно они изменили поведение, которое README -описывает (сессии, ротация лога, обязательность `SELFPOST_HOSTNAME`). +| Дом | Файл | +|---|---| +| Пользовательская поставка | [README.md](../README.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) | -**Модель:** документация → Sonnet (правило [progress.md](progress.md)). -Исключение — **D6** (HEALTHCHECK/эндпоинт мониторинга) и формулировки про -`TRUSTED_PROXY_CIDR`: инфра/безопасность → Opus. +Отложенная полировка v1.x (Quick start на Codeberg, тег образа в compose, +`docs/logo`) — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя». --- ## 1. Состав пакета -### Поставляемое пользователю (обязательно по ТЗ) +### Поставляемое пользователю -| Артефакт | Требование | Состояние | -|---|---|---| -| [README.md](../README.md) | ТЗ 11 п. 7: установка, требования к площадке, per-domain DNS, прогрев IP, **эксплуатация**, бэкап/восстановление и экспорт/импорт домена (оба файла — секреты), репозиторий (Codeberg основной / GitHub зеркало), откуда образ (`ghcr.io`), фиксированный тег, минимальные требования к машине, лицензия в 2–3 предложениях | Есть всё, **кроме раздела «эксплуатация»**; см. находки 1–4, 10 | -| [LICENSE](../LICENSE) | ТЗ 11 п. 9: полный текст AGPL-3.0 | Полный текст на месте, правок не требует | -| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + фрагменты прокси | ТЗ 11 п. 5, ТЗ 10 п. 3: Apache основной, nginx/Caddy/Traefik альтернативами; для каждого — что монтируется и в какие пути | Все четыре есть ([apache](../deploy/apache/), [nginx](../deploy/nginx/), [caddy](../deploy/caddy/), [traefik](../deploy/traefik/)), в README сведены таблицей; см. находки 7, 8 | -| [deploy/.env.example](../deploy/.env.example) | Пользовательские переменные с пояснениями | Есть 6 переменных; полного справочника нет — находка 3 | -| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog, запись на каждом осмысленном шаге | Ведётся; `[Unreleased]` наполнен B.1–B.3, C.4 | +| Артефакт | Состояние | +|---|---| +| [README.md](../README.md) | Установка, площадка, DNS, прогрев IP, эксплуатация, rate limiting, env, бэкап, репозиторий, образ, лицензия | +| [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) | Публичные переменные; полный справочник в README | +| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog | -### Рабочие документы проекта (не поставка, но обязаны быть верны) +### Рабочие документы -[archive/specification-v1.0.md](archive/specification-v1.0.md) — исторический снимок ТЗ v1.0 (D9 закрыт). -[implementation-plan.md](implementation-plan.md) — открытые вопросы для v1.0/v1.x. -[roadmap.md](roadmap.md) — линия 2.x.x. -[progress.md](progress.md) — живой трекер, читается первым после `/clear`. -[security.md](security.md) — обязательные требования безопасности + принятые -риски. Этот файл — план по документации. +[progress.md](progress.md), [implementation-plan.md](implementation-plan.md), +[roadmap.md](roadmap.md), этот файл. -**Новые `product.md`, `architecture.md`, `development.md` — решение принято.** -В обязательную поставку пользователю (README) они не входят, но без них -понимание проекта держится на памяти сессии. Их содержимое **заменяет** живой -`specification.md` для всех будущих проходов — задачи **D8** и **D9**. - -- `docs/product.md` — из ТЗ §1–3 и §4.1: зачем SelfPost, инфраструктурные - предпосылки, out of scope, мультидоменная модель «домен ↔ приложения ↔ - режим From». Границы продукта, меняющиеся только явным решением. -- `docs/architecture.md` — as-built по коду: `supervisord`, `postfix`, - `opendkim`, panel (HTTP + journal-milter + log-tailer); порядок старта; - milter-цепочка; Postfix/SASL/TLS; журнал отправки и L1/L2 rate-limit; - бэкап/restore; персистентность `/data`. Источник истины — код, не ТЗ. -- `docs/development.md` — Go локально, `go test`/`go vet`, `make e2e`, когда - нужен полный контейнер на dev-сервере, ручная проверка на - `selfpost.mixfed.ru`, протокол коммитов/CHANGELOG, правила для агента - (бывшее ТЗ §12). В `progress.md` — текущее состояние, не процедура. - -### Карта миграции из `specification.md` - -| Блок ТЗ | Новый дом | Задача | -|---|---|---| -| §1–3, §4.1 | `product.md` | D9 | -| §4, §5–7 (техника), §9 | `architecture.md` | D8 | -| §7.6 | `security.md` («Обязательные требования») | D9 | -| §8 | README (таблица env) | D2 | -| §9 (путь `tar` для пользователя) | README | D3 | -| §10–11 | README + таблица «Поставка» выше | D1–D4 | -| §12 | `development.md` | D8 | - -После D9: `specification.md` → `docs/archive/specification-v1.0.md` (снимок, -не правится); в `docs/` живых ссылок на него нет. - -**Границы (без изменений):** отдельные `CONTRIBUTING.md`, man-страницы и сайт -документации в объём v1.x не входят. +**Вне объёма v1.x:** `CONTRIBUTING.md`, man-страницы, отдельный сайт документации. --- ## 2. Метод сверки с кодом -Правило: у каждого утверждения в документации есть **ровно один источник истины -в дереве**, и сверка идёт от кода к тексту (что код делает → сказано ли об -этом), а не наоборот — иначе не видно того, что забыли описать. +Правило: у каждого утверждения в документации есть **источник истины в дереве**; +сверка идёт от кода к тексту. | Класс утверждений | Источник истины | |---|---| -| Переменные окружения, значения по умолчанию | `loadConfig`/`envDefault`/`envInt` — [cmd/panel/main.go:80](../cmd/panel/main.go:80)–159; `${VAR:-default}` в [build/](../build/) (`postfix-config.sh`, `postfix-wrapper.sh`, `postfix-cert-reload.sh`, `logrotate-loop.sh`, `entrypoint.sh`) | -| Поведение почтового тракта (порты, TLS, SASL, анти-relay, лимиты, milter-цепочка) | [build/postfix-config.sh](../build/postfix-config.sh) | -| Экраны и действия панели (что вообще можно делать в эксплуатации) | таблица маршрутов [internal/web/web.go:133](../internal/web/web.go:133)–190 | -| Бэкап/восстановление, экспорт/импорт домена, проверка версии | [internal/backup/backup.go](../internal/backup/backup.go), [cmd/selfpost-backup/main.go](../cmd/selfpost-backup/main.go) | -| Сессии, вход, смена пароля | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go), [internal/web/handlers_account.go](../internal/web/handlers_account.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) | -| Деплой: тег образа, порты, монтирования, capabilities | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) | -| Обязательное содержание README (чеклист поставки) | таблица «Поставка» в §1 этого файла (бывшее ТЗ §10–11) | -| Границы продукта, out of scope | [product.md](product.md) *(после D9)* | -| As-built устройство (не пользовательский текст) | [architecture.md](architecture.md) *(после D8)* | -| Обязательные требования безопасности | [security.md](security.md) *(после D9)* | +| 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 | таблица «Поставляемое пользователю» выше | +| Продукт, out of scope | [product.md](product.md) | +| As-built | [architecture.md](architecture.md) | +| Обязательная безопасность | [security.md](security.md) | -Порядок прохода: сначала перечислить фактическое (env-ключи, маршруты, -`postconf`-настройки), потом искать каждый пункт в README / `architecture.md` — -так находится и неверное, и **отсутствующее**. До закрытия D9 для чеклиста -README допустима сверка с `specification.md` §10–11; после D9 — только с §1 -этого файла. +Порядок: перечислить фактическое в коде → найти в README / `architecture.md`. +Перед каждым тегом — короткий проход по этой таблице, не полная ревизия текста. --- -## 3. Результаты первого прохода (выполнен) +## 3. Правила поддержки -Сверено: env-переменные, маршруты панели, конфигурация Postfix, бэкап/CLI, -сессии, ротация, compose/Dockerfile. Ниже — всё найденное, по убыванию -приоритета. Нумерация сквозная, на неё ссылаются задачи в разделе 4. - -**Высокий (пробел против ТЗ или вводит в заблуждение)** - -1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7 - ([specification.md:427](specification.md)). В README нет ни слова про - `/status` (проверки PTR/hostname), `/deliveries` (журнал отправки, фильтры), - `/mail-queue` (очередь Postfix), `/system-log` (хвост `mail.log`), `/reload`, - `/account`, `/backup` — при том что всё это реализовано - ([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и - процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел - «Fixed image tag» на неё намекает. -2. **Битая ссылка + недокументированные лимиты.** - [deploy/.env.example:13](../deploy/.env.example:13) отсылает к разделу README - «Rate limiting», которого в README нет. Сам двухуровневый лимит не описан - нигде для пользователя: L1 — `smtpd_client_message_rate_limit` + - `anvil_rate_time_unit` ([build/postfix-config.sh:110](../build/postfix-config.sh:110)), - L2 — per-domain/per-app из панели - ([internal/web/web.go:165](../internal/web/web.go:165), - [:169](../internal/web/web.go:169)), с записью `rejected` в журнал. -3. **Нет справочника переменных окружения.** В `.env.example` шесть штук; код - читает заметно больше. Панель: `SELFPOST_DATA_DIR`, `SELFPOST_DB_PATH`, - `SELFPOST_SETUP_TOKEN_FILE`, `PANEL_HTTP_ADDR`, `JOURNAL_MILTER_SOCKET`, - `MAIL_LOG`, `PANEL_COOKIE_SECURE`, `TLS_CERT_FILE`, `OPENDKIM_SOCKET`, - `OPENDKIM_DIR`, `DKIM_SELECTOR_DEFAULT`, `SASL_DB_PATH`, `SASL_REALM`, - `POSTFIX_DIR` ([cmd/panel/main.go:80](../cmd/panel/main.go:80)–127). - Обвязка: `TLS_KEY_FILE`, `POSTFIX_SENDER_LOGIN_MAPS`, - `MILTER_CONNECT_TIMEOUT`/`COMMAND`/`CONTENT` (15s/15s/30s, - [build/postfix-config.sh:133](../build/postfix-config.sh:133)), - `MILTER_WAIT_TIMEOUT`, `TLS_RELOAD_INTERVAL_SECONDS` (86400, - [build/postfix-cert-reload.sh:14](../build/postfix-cert-reload.sh:14)), - `LOGROTATE_INTERVAL_SECONDS` (21600, - [build/logrotate-loop.sh:17](../build/logrotate-loop.sh:17)). - Отдельно: **`TRUSTED_PROXY_CIDR`** — переменная с последствиями для - безопасности (доверие `X-Forwarded-For` при rate-limit логина), описана - только в `.env.example`, в README её нет вообще. - *Решение, которое надо принять в задаче D2:* какие переменные публичные - (таблица в README), а какие внутренние (достаточно комментария в коде) — - документировать все 20+ вредно, они станут «поддерживаемым интерфейсом». -4. **Оговорка ТЗ 9 про прямой `tar` не отражена.** - [specification.md:375](specification.md) требует описать оба пути: «бэкап на - лету — панель/CLI» и «прямой `tar` директории безопасен, если контейнер - остановлен». В README ([Backup, restore…](../README.md)) есть только первый, - поэтому у читателя нет ответа на очевидный вопрос «а можно просто - заархивировать `./data`?». - -**Средний (стало неверным после последних фаз)** - -5. **Статус-баннер устарел.** [README.md:12](../README.md:12)–15: «under active - development» и ссылка на `implementation-plan.md` как на «phased build plan» - — фазы 0→14 закрыты, план теперь про открытые вопросы и линию 2.x. Перед - тегом релиза баннер переформулировать (или снять), ссылки уточнить. -6. **`implementation-plan.md` разошёлся с кодом.** Пункт B.1 говорит: «смена - пароля завершает **все** сессии, включая ту, из которой её делают → редирект - на `/login`». Код делает иначе — гасит все, **кроме** текущей - ([internal/store/sessions.go:95](../internal/store/sessions.go:95) - `DeleteOtherSessions`, [internal/web/session.go:131](../internal/web/session.go:131)), - и панель так и пишет («Any other signed-in sessions were signed out», - [internal/web/handlers_account.go:49](../internal/web/handlers_account.go:49)). - README здесь **верен**. Расхождение в плане — зафиксировать как осознанное - изменение при реализации, а не молча переписать. -7. **Комментарий в compose вводит в заблуждение.** - [deploy/docker-compose.yml:14](../deploy/docker-compose.yml:14) велит - «fill in .env (hostname, at least one strong TLS_CERT/KEY path)», но - `TLS_CERT_FILE`/`TLS_KEY_FILE` захардкожены в самом файле - ([:30](../deploy/docker-compose.yml:30)–31) и в `.env.example` отсутствуют — - настраивается на деле **bind mount** `./certs`, а не переменные. -8. **Порт 587 публикуется всегда** ([deploy/docker-compose.yml:61](../deploy/docker-compose.yml:61)), - хотя listener появляется только при `SUBMISSION_ENABLE=true` - ([build/postfix-config.sh:151](../build/postfix-config.sh:151)). Безвредно - (слушать некому), но выглядит как лишний открытый порт — одна строка - пояснения в README/compose снимает вопрос. - -**Низкий / требует решения, а не только текста** - -9. **`/healthz` есть, `HEALTHCHECK` нет.** Эндпоинт реализован - ([internal/web/web.go:133](../internal/web/web.go:133)), в - [build/Dockerfile](../build/Dockerfile) объявления `HEALTHCHECK` нет (пробел - отмечен и в плане, п. B.3). Решить в D6: либо задокументировать `/healthz` - как точку внешнего мониторинга, либо добавить `HEALTHCHECK` в образ (это уже - код + строка в CHANGELOG). Документировать «здоровье контейнера» до принятия - решения нельзя — получится обещание, которого образ не даёт. -10. **Из B.1–B.3/C.4 в README попала только обязательность `SELFPOST_HOSTNAME`.** - Не описаны: сессия переживает рестарт и живёт по скользящему сроку - бездействия (`PANEL_SESSION_IDLE_DAYS`, «вкладка с автообновлением вход не - продлевает» — контринтуитивно и заслуживает строки), ротация `mail.log` - (14 файлов, проверка каждые 6 ч, суточный `postfix reload`) — README - упоминает только «kept 14 days in-image» в требованиях к машине. -11. **Мелочи, без правки кода:** Quick start тянет файлы с - `raw.githubusercontent.com` (зеркало), хотя основной репозиторий — - Codeberg; тег образа `1.0.0` в compose ([:24](../deploy/docker-compose.yml:24)) - придётся бампить в тот же коммит, что и релиз; пустой каталог `docs/logo`. - -**Проверено и расхождений не найдено** (важно не переделывать): требования к -площадке; DNS-раздел (совпадает с тем, что реально проверяет `/status` и -DNS-карточка домена); прогрев IP; смысл фиксированного тега; проверка версии при -восстановлении — README описывает её точно так, как ведёт себя `CheckRestore` -([internal/backup/backup.go](../internal/backup/backup.go)); форма вызова -`docker exec … selfpost-backup > …` ([cmd/selfpost-backup/main.go:7](../cmd/selfpost-backup/main.go:7)); -таблица reverse-proxy и требование пропускать `Host`; секретность архива и -экспорта домена; лицензия; ретенция журнала отправки (90 дней) и то, что она — -основной драйвер роста `/data`. +1. **Правило шага:** новая/переименованная env-переменная, маршрут панели или + наблюдаемое поведение почтового тракта закрываются вместе с README / + `.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. Задачи +## 4. Гейт релиза (документация) -Порядок — как перечислены; D1–D3 самые крупные. Каждая заканчивается записью в -`[Unreleased]` [CHANGELOG.md](../CHANGELOG.md) и коммитом. - -**D1. README: раздел «Operations» + «Rate limiting».** Закрывает находки 1, 2 и -часть 10. Что описать: экраны панели и зачем они нужны (`/status` и что именно -он проверяет; журнал отправки со статусами `queued/sent/rejected`; очередь; -хвост `mail.log`; `/reload`; смена учётных данных); двухуровневый лимит с -именами переменных L1 и указанием, что L2 задаётся в панели per-domain/app; -процедура апгрейда версии; поведение сессии (скользящий срок, опросы не -продлевают, смена пароля гасит остальные). Тон и объём — как в существующих -разделах: практика, а не пересказ ТЗ. -*Готово, когда:* каждый маршрут из [internal/web/web.go:152](../internal/web/web.go:152)–188, -кроме HTMX-фрагментов, либо описан, либо сознательно опущен; ссылка -«Rate limiting» из `.env.example` ведёт в существующий якорь. - -**D2. Справочник переменных окружения.** Закрывает находку 3. Сначала решение о -границе публичного набора, затем таблица (имя, назначение, дефолт, где -задаётся) в README + синхронизация `.env.example` и комментариев в compose. -`TRUSTED_PROXY_CIDR` описать с прямым предупреждением: неверное значение -позволяет подделать ключ rate-limit'а логина. -*Готово, когда:* каждый ключ из `loadConfig` и из `${VAR:-…}` в `build/*.sh` -попал либо в таблицу, либо в явный список «внутренние, не интерфейс»; дефолты в -таблице совпадают с кодом дословно. - -**D3. Бэкап: дописать путь «остановленный контейнер + `tar`».** Закрывает -находку 4 — по формулировке [specification.md:375](specification.md), с явным -«на живом контейнере так делать не надо, потому что SQLite в WAL». Заодно -упомянуть, что `manifest.json` после успешного восстановления потребляется. - -**D4. Точечные правки.** Находки 5, 7, 8, 11: баннер статуса и ссылки (убрать -ссылку на `specification.md` — после D9 продукт в `product.md`); комментарий -в шапке compose про `.env`; строка про 587; бамп тега образа (делается в -релизном коммите, не раньше). - -**D5. Синхронизация внутренних документов.** Находка 6 — поправить B.1 в -`implementation-plan.md` (с пометкой, что решение изменилось при реализации и -почему), заодно перечитать `progress.md` на предмет утверждений, которые код уже -опроверг. Не переписывать историю в CHANGELOG. - -**D6. Решение по `HEALTHCHECK`/`/healthz` (Opus).** Находка 9: либо строка в -`Dockerfile` + описание, либо только описание эндпоинта. Влияет на код образа — -поэтому отдельной задачей и не на Sonnet. - -**D7. Регресс-защита (см. раздел 5).** - -**D8. Новые документы: `architecture.md` и `development.md`.** Пишутся с нуля и -перенимают техническую часть ТЗ (§4–7, §9, §12) в форму as-built / процесса. -`architecture.md` сверяется методом из раздела 2 — по коду, не по памяти. -`development.md` фиксирует воспроизводимый процесс (Go локально / e2e / -dev-сервер). -*Готово, когда:* `architecture.md` перечисляет все managed-процессы из -[build/supervisord.conf](../build/supervisord.conf) и путь письма через -milter-цепочку без противоречий коду; `development.md` позволяет с нуля -повторить локальную сборку + прогон тестов и понять, когда нужен dev-сервер, -не заглядывая в память проекта и не открывая `specification.md`. - -**D9. Вывод `specification.md` из обращения (после D1–D8).** Закрывает цель -плана: живой ТЗ больше не нужен. -1. Создать `docs/product.md` — §1–3, §4.1 (см. карту миграции в §1). -2. Дополнить [security.md](security.md) разделом «Обязательные требования» — - самодостаточный чеклист из бывшего §7.6 (setup-link, SASL, сессии, - rate-limit логина, `html/template`, не-root и т.д.), без отсылки «см. ТЗ». -3. Убедиться, что D1–D4 и D8 покрыли всё из §8–11, что должно жить в README / - `architecture.md` (пройти карту миграции построчно). -4. Перенести `specification.md` → `docs/archive/specification-v1.0.md` без - правок текста; в начале архива — одна строка: «исторический снимок v1.0, - не источник истины». -5. Обновить ссылки во всём репозитории: `progress.md` (убрать «ТЗ: - specification.md», заменить на `product.md` + `architecture.md`), - `implementation-plan.md` и `roadmap.md` («Основа» → `product.md`), - `security.md`, `README.md` (баннер D4 — не на ТЗ). `rg specification\.md` - по репо — только архив и CHANGELOG/история. -*Готово, когда:* в `docs/` нет `specification.md`; новый агент после `/clear` -может понять продукт, устройство, безопасность и процесс разработки, не -открывая архив. - ---- - -## 5. Чтобы не разошлось снова - -1. **Правило шага:** изменение, добавляющее/переименовывающее env-переменную, - маршрут панели или наблюдаемое поведение почтового тракта, закрывается - только вместе с правкой README/`.env.example` — в том же коммите, наравне с - записью в CHANGELOG (протокол закрытия шага в [progress.md](progress.md)). -2. **Дешёвая машинная проверка (D7):** тест или скрипт, сверяющий множество - ключей `loadConfig` со списком, объявленным в документации, и падающий на - новом недокументированном ключе. Ловит самый частый класс расхождений - (находка 3) без ручного прохода. Границу «внутренних» ключей задать явным - списком-исключением в самом тесте. -3. **Перед каждым тегом** — короткий проход по разделу 2 (источники истины в - коде + README + `architecture.md` + `product.md`), а не полная ревизия текста - и не сверка с архивным ТЗ. - ---- - -## 6. Гейт релиза - -Документационный проход — часть того же гейта, что e2e (C.4) и ревизия -безопасности (D.5): **D1–D6 и D9 закрыты до тега**. D7 желателен, но тег не -блокирует. **D8** — обязателен до D9 (архитектура должна существовать до -миграции); D8 один тег не блокирует, если D9 отложен, но **полное закрытие -плана (= вывод specification) — только D1–D9 вместе**. Находки, обнаруженные -позже, дописываются сюда, а не исправляются молча: этот файл — журнал -состояния документации, а не одноразовый список дел. +Документационный проход **D1–D9 закрыт.** До тега релиза остаются другие пункты +общего гейта: e2e (C.4), ревизия безопасности (D.5 в +[implementation-plan.md](implementation-plan.md)) — см. [progress.md](progress.md). diff --git a/docs/progress.md b/docs/progress.md index 9cea32a..0b9daf8 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -44,7 +44,7 @@ - **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.mixfed.ru`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на 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.mixfed.ru`, отдельный образ `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` — локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `+` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.mixfed.ru`)**: `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) — D6 HEALTHCHECK/`/healthz`, D7 env-doc regression test, D8 `architecture.md`/`development.md`, D9 вывод `specification.md` (архив в `docs/archive/`, живые документы `product.md` + расширенный `security.md`). Часть предрелизного гейта наравне с e2e и ревизией безопасности. +- **Документация:** план D1–D9 закрыт ([documentation-plan.md](documentation-plan.md) — только метод и правила поддержки). Хвост v1.x (Codeberg в Quick start, тег образа, `docs/logo`) — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя». - **Дальше:** пункт **D.5** плана — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (C.4, готов) это гейт перед тегом релиза. - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). diff --git a/docs/roadmap.md b/docs/roadmap.md index 2afc2f2..1cbb6e6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,14 +2,40 @@ **Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация — -только после явного согласования (ТЗ 12.6): ТЗ v1.0 явно исключает часть этого -объёма (приём входящей почты — раздел 3; несколько пользователей/роли — -раздел 3) из объёма, поэтому включение — сознательное расширение границ -проекта, а не доработка по своей инициативе. Присутствие пункта здесь -фиксирует намерение и дизайн; кодирование начинается отдельным решением. +только после явного согласования ([product.md](product.md), [development.md](development.md) § Agent rules): +[product.md](product.md) явно исключает часть этого объёма (приём входящей +почты; несколько пользователей/роли), поэтому включение — сознательное +расширение границ проекта, а не доработка по своей инициативе. Присутствие +пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным +решением. **Основа:** [product.md](product.md) v1.0. Несделанное для v1.0/v1.x -— в [implementation-plan.md](implementation-plan.md). +— в [implementation-plan.md](implementation-plan.md). Хвост закрытого +документационного прохода (D1–D9) — в секции ниже. + +--- + +## v1.x — хвост документации и деплоя + +**Статус:** не блокирует релизный тег; перенесено из закрытого +[documentation-plan.md](documentation-plan.md) (бывшая находка 11 и отложенный +пункт D4). Делать по желанию или в релизном коммите, где указано. + +**Quick start на Codeberg.** [README.md](../README.md) Quick start тянет +`docker-compose.yml` и `.env.example` с `raw.githubusercontent.com` (зеркало). +Основной репозиторий — Codeberg; заменить URL на актуальные raw-ссылки +Codeberg (`codeberg.org/mix/selfpost/raw/branch/main/deploy/...`). + +**Тег образа в compose.** В [deploy/docker-compose.yml](../deploy/docker-compose.yml) +поле `image:` бампить до версии релиза **в том же коммите**, что и git-тег +`vX.Y.Z` — не раньше. Сейчас может отставать от целевой версии релиза; +несовпадение мешает только до первого выката по тегу. + +**Каталог `docs/logo`.** Пустой; либо наполнить (если нужен отдельный asset для +внешних ссылок), либо удалить каталог, чтобы не создавать ложное ожидание. + +**Готово, когда:** Quick start указывает на Codeberg; тег образа в compose +совпадает с релизом; `docs/logo` либо содержит файлы, либо отсутствует. --- @@ -23,7 +49,7 @@ **Граница объёма (критично — что это НЕ):** - **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки. -- **ЭТО НЕ (остаётся out of scope, ТЗ 3):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра. +- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра. **Почему как опция/плагин:** - Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора. @@ -34,25 +60,25 @@ - **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`. - **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны. - **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса. -- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell (ТЗ 7.6.2–4). +- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)). - **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется. - **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd. **Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения: -- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам). +- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам). - **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации. - **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend. - **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции. - **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить. - **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README. -**Безопасность (ТЗ 7.6 распространяется полностью):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter. +**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter. **Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные. **Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции. -**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования. +**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([development.md](development.md)) до кодирования. --- @@ -60,6 +86,9 @@ **Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go:182](../internal/web/web.go:182)), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([handlers_monitor.go:49](../internal/web/handlers_monitor.go:49)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны. -**Почему 2.x, а не v1.x.** ТЗ 3 относит «несколько пользователей панели, роли» к явным не-целям (панель рассчитана на одного администратора), поэтому появление второго субъекта — расширение границ проекта, как и Фаза O1: сначала согласование (ТЗ 12.6) и правка ТЗ, только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена. +**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей +панели, роли» к out of scope (один администратор), поэтому появление второго +субъекта — расширение границ проекта, как и Фаза O1: сначала согласование +([development.md](development.md)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена. *(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*