diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f0305..dbd8c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release + security review (§ D); closed B.1–C.4 material moved to as-built and ops docs. +- docs: `architecture.md` — sessions (SQLite, idle renew, password change), + `mail.log` rotation (rename + `postfix reload`), `SELFPOST_HOSTNAME` startup + gate, log-tailer known gaps. +- docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. +- docs: `security.md` — accepted risks for session rows restored from backup and + send-log rows stuck at `queued` after panel restart or container recreate. +- docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. +- docs: `progress.md`, `documentation-plan.md` — cross-links updated for the new layout. - 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 diff --git a/docs/architecture.md b/docs/architecture.md index f92f353..69f723e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,8 +12,15 @@ User install/operations: [README.md](../README.md). Product boundaries: ## Image and processes Single Debian slim image. `entrypoint.sh` (root) fixes `/data` ownership and -milter socket directories, validates `SELFPOST_HOSTNAME`, then execs -`supervisord` as PID 1. +milter socket directories, **requires** `SELFPOST_HOSTNAME` (FQDN with at least +one dot; no scheme, port, or spaces — invalid or empty value → `exit 1` before +Postfix config or supervisord), then execs `supervisord` as PID 1. + +The hostname is not a tunable default: it must match PTR/rDNS, TLS CN/SAN, and +SASL realm together. Soft fallbacks (`localhost` in the panel vs container ID in +Postfix) split realms and break SMTP AUTH with a silent `535` in clients while +the panel looks healthy. Shipped `docker-compose.yml` already requires the +variable; the entrypoint gate catches `docker run`, custom compose, and k8s. Managed programs ([build/supervisord.conf](../build/supervisord.conf)): @@ -76,10 +83,32 @@ One process, three roles: Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** (`default_action=accept`) so milter failure does not stop mail. 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by - queue-id. + queue-id. Send-log `queued → sent` transitions depend on this goroutine alone + (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). +### Log tailer and `mail.log` rotation + +`mail.log` lives under `/var/log` (not in `/data`). Rotation uses rename + +`postfix reload` ([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not +`copytruncate` — the latter can drop `status=sent` lines and leave send-log rows +stuck at `queued`. After rename, logrotate runs `create 0644 root root` (Postfix +recreates the file lazily on first write as mode `0600`, which the unprivileged +panel user cannot read). `follow()` drains the old inode once more before +switching descriptors; the panel treats a missing log file as an empty tail, not +an error. + +**Known gaps (same class of loss, not fixed by rename rotation):** + +- **Panel restart** — `follow()` starts at end-of-file; lines written while the + panel was down are never parsed; in-flight send-log rows may stay `queued`. +- **Container recreate** — `/var/log` is ephemeral; the log is lost with the + container. + +Possible follow-ups if these become painful: persist read offset across restarts, +mount mail log under `/data`, or reconcile stuck rows via `postqueue`. + --- ## Panel HTTP surface @@ -102,7 +131,26 @@ unless noted. | `/account` | Admin username/password | HTMX polling refreshes monitoring fragments; polling does not extend session -idle timeout. +idle timeout (only non-`HX-Request` GET and mutating requests count as activity). + +### Sessions + +Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random +token; the database stores **SHA-256 of the token**, not the token itself — a +stolen DB or backup archive does not alone grant login, but a browser that still +holds the cookie works after process restart, redeploy, or full backup restore. + +- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no + absolute cap (regular use keeps the session alive indefinitely). +- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour + (`renewThreshold` in [internal/web/session.go](../internal/web/session.go)). +- **Password change** — all other sessions are deleted; the current session stays + active ([internal/store/sessions.go](../internal/store/sessions.go), + [handlers_account.go](../internal/web/handlers_account.go)). + +Restoring an **older** backup also restores session rows: a session invalidated +after that backup was taken can become valid again if the browser still has the +cookie and idle timeout has not expired. --- @@ -121,7 +169,8 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue (transit mail not migrated by design). **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); -`mail.log` via logrotate (14 files, check every 6h, `postfix reload` on rotate). +`mail.log` via logrotate (14 rotated files, check every 6h, rename + +`postfix reload` in `postrotate` — see § Log tailer above). **Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of `/data` tree; version check on restore. Stopped-container `tar` of `./data` is diff --git a/docs/development.md b/docs/development.md index a0bf999..a6911dc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,19 +46,35 @@ new `loadConfig` keys must appear in README env lists ## End-to-end tests -Hermetic container suite (plan C.4): +Hermetic container suite (implemented as `test/e2e/`, separate Go module): ```sh make e2e # equivalent: cd test/e2e && go test -v -timeout 20m ./... ``` -Uses `deploy/docker-compose.yml` + `test/e2e/compose.override.yml` (high -ports, test hostname, fake DNS zone, smtp-sink). Requires **Docker + Compose v2** -on the machine running tests. Not included in `go test ./...` of the main module. +**Stack:** shipped [deploy/docker-compose.yml](../deploy/docker-compose.yml) + +[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same +`cap_drop`/`cap_add`/`no-new-privileges` as production. Override uses high +ports (`20465`/`20587`/`20080`), test hostname, self-signed TLS, +`PANEL_COOKIE_SECURE=false`, isolated compose project. Mail is hermetic: CoreDNS +fake zone + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from the panel +and published into the zone so the test verifies the record the operator would +use. -CI (`release.yml`): matrix build → e2e per arch → push `ghcr.io` on tag -`vX.Y.Z`. +**Coverage (summary):** full bootstrap → SMTP AUTH → delivery → DKIM verify → +send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch, L1/L2 +limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives +`docker restart`). Polling with timeouts only — no fixed `sleep`. + +Requires **Docker + Compose v2** on the test host. Not included in `go test ./...` +of the main module. + +**CI** ([release.yml](../.github/workflows/release.yml)): tag `vX.Y.Z` triggers +`prepare` (version from tag) → native matrix `[ubuntu-latest, ubuntu-24.04-arm]` +— build `--load`, e2e, push per-arch tag → `merge` publishes unified +`ghcr.io/...:X.Y.Z` via `docker buildx imagetools create`. Failed e2e blocks the +image. Ordinary pushes still run only `vet`/`test` in [test.yml](../.github/workflows/test.yml). --- diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md index 2081998..a6df24a 100644 --- a/docs/documentation-plan.md +++ b/docs/documentation-plan.md @@ -80,6 +80,6 @@ ## 4. Гейт релиза (документация) -Документационный проход **D1–D9 закрыт.** До тега релиза остаются другие пункты -общего гейта: e2e (C.4), ревизия безопасности (D.5 в -[implementation-plan.md](implementation-plan.md)) — см. [progress.md](progress.md). +Документационный проход **D1–D9 закрыт.** До тега релиза остаётся общий гейт: +e2e (готов, [development.md](development.md)) и ревизия безопасности +([implementation-plan.md](implementation-plan.md) § D) — см. [progress.md](progress.md). diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index fc3ec55..5516ecb 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,124 +1,38 @@ # План реализации: SelfPost -**Статус:** выполненные фазы здесь не описываются — текущее состояние в -[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md) -и `git log`. Ниже остаётся только то, что **ещё не сделано** для линии -v1.0/v1.x: открытые вопросы для согласования. Объём релизной линии 2.x.x -(входящий релей, роль администратора домена) вынесен в -[roadmap.md](roadmap.md). +**Статус:** для линии v1.0/v1.x до тега релиза остаётся **один пункт** (ниже). +B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md), +e2e/CI в [development.md](development.md), принятые риски в +[security.md](security.md). Текущее состояние и следующий шаг: +[progress.md](progress.md). Объём 2.x.x — [roadmap.md](roadmap.md). **Основа:** [product.md](product.md) v1.0. --- -## Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0) +## D. Предрелизная ревизия безопасности -Ниже — то, что **выходит за букву ТЗ**, но заслуживает решения перед тем, как -считать v1.0 «финальным». Ничего из этого **не является дефектом -соответствия**; это осознанные компромиссы и потенциальные улучшения. Каждый -пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», -принимается пользователем. +**Проверка на уязвимости моделью Fable** — после B.1–B.3 и C.4, **до тега +релиза**. Не выполнено. -**Раздел A (принятые риски безопасности) переехал в -[security.md](security.md)** — риск не задача, а решение, и в плане несделанной -работы ему делать нечего. Буквы разделов и сквозная нумерация пунктов ниже -оставлены как были: на них ссылаются `progress.md`, коммиты и обсуждения. +**Почему один проход по итоговому состоянию, а не по каждому пункту.** B.1–C.4 +меняли одну и ту же поверхность (сессии в SQLite, logrotate + `postfix reload`, +гейт `SELFPOST_HOSTNAME`, e2e-override с ослабленными настройками). Срезы по +отдельным коммитам не заменяют просмотр дифа от `v1.0.0` до HEAD. -### B. Надёжность и эксплуатация +**Объём.** Диф от тега `v1.0.0` до состояния перед следующим тегом — вместе с +Фазами 12–14, которых в аудите v1.0 не было — плюс повторный проход по чек-листу +ТЗ 7.6 целиком, а не только по изменённым строкам. Приоритет: аутентификация и +сессии, валидация ввода, запись в конфиги и map-файлы (injection), `os/exec` без +shell, права на файлы в `/data`, обращение с секретами (пароли приложений, +`sasldb2`, архив бэкапа). -1. **Сессии — решено: хранить в БД, скользящий срок бездействия.** Прежнее поведение (только в памяти, absolute TTL 12 ч) заменяется на: - - таблица `sessions` в SQLite (миграция `0002`) — вход переживает рестарт, редеплой и восстановление из полного бэкапа. В БД лежит SHA-256 от токена, а не сам токен: украденный файл БД или архив бэкапа во вход не превращается, зато браузер, у которого есть исходная cookie, работает и после восстановления; - - срок — **скользящий, 7 дней бездействия**, задаётся `PANEL_SESSION_IDLE_DAYS` (целое число дней, как `SEND_LOG_RETENTION_DAYS`). Абсолютного потолка нет **сознательно**: у админа, заходящего регулярно, сессия живёт неограниченно долго; - - **мониторинговые опросы сессию не продлевают.** Четыре фрагмента (`/status/fragment`, `/queue/body`, `/logtail/body`, `/sendlog/rows`) опрашивают сервер `every 5s`; продлевай их — и забытая открытая вкладка держала бы вход вечно, а «7 дней бездействия» означало бы «7 дней без открытой вкладки». Активностью считается переход по странице или действие, то есть всё, кроме GET-запросов с заголовком `HX-Request`; - - `Max-Age` cookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик); - - смена пароля завершает **все остальные** сессии; текущая остаётся активной (`DeleteOtherSessions` в [sessions.go](../internal/store/sessions.go)). **Изменение при реализации:** первоначально планировалось «все, включая текущую → редирект на `/login`»; от этого отказались — оператор, только что сменивший пароль, не должен повторно входить; панель сообщает «Any other signed-in sessions were signed out» ([handlers_account.go](../internal/web/handlers_account.go)). +**Модель — Fable** (не Opus): код писал Opus, проверка собственной работы +систематически слабее независимой. Правило progress.md «безопасность/инфра → +Opus» — про написание; здесь ревизия. Форма: `/security-review` по изменениям +(скилл смотрит диф) + ручной проход по 7.6. - Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк. -2. **Ротация `mail.log` — решено: отказаться от `copytruncate` в пользу «переименовать + `postfix reload`».** Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки — `UpdateStatus` вызывается **только** из [internal/logtail](../internal/logtail/logtail.go), больше ниоткуда. Значит потерянная строка `status=sent` — это строка журнала отправки, навсегда застрявшая в `queued`, то есть тихая порча данных, а не пробел в мониторинге. Окон потери при `copytruncate` два: - - **до одного интервала опроса (1 с) строк** — записанное после последнего `drain()` и до `truncate` физически сохранено в `mail.log.1`, но дескриптор тейлера смотрит на уже обрезанный inode и это пропускает. Это доминирующее окно; - - **миллисекунды между «`cp` дочитал до EOF» и `truncate`** — эти строки не попадают никуда; для `copytruncate` устранить нельзя. - - **Решение** — ротация переименованием, ровно та механика, которую применяет сам Postfix в `postfix logrotate` (`mv`, затем `HUP` мастеру): rename атомарен, postlogd продолжает писать в переименованный inode до перезапуска, а тейлер держит дескриптор на том же inode и дочитывает хвост перед переключением на новый файл. Не теряется ничего ни на стороне записи, ни на стороне чтения. Правки: - - [build/logrotate-mail.conf](../build/logrotate-mail.conf): убрать `copytruncate`, добавить `postrotate /usr/sbin/postfix reload endscript`. `rotate 14`/`compress`/`delaycompress` остаются: удержание N файлов (ТЗ 9) — за logrotate, поэтому берётся не сам `postfix logrotate` (у него нет retention, он лишь переименовывает с меткой времени и жмёт), а его механика. **`create 0644 root root`, не `nocreate`** — см. стендовую проверку ниже, предположение о `nocreate` не подтвердилось; - - `follow()` в [internal/logtail/logtail.go](../internal/logtail/logtail.go): при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно между `drain()` и проверкой смены файла. Проверка `ni.Size() < pos` сохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом; - - `readLogTail()` в [internal/web/handlers_monitor.go](../internal/web/handlers_monitor.go): `fs.ErrNotExist` — не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум. - - **Цена:** один `postfix reload` в сутки — ровно то, что уже делает [postfix-cert-reload.sh](../build/postfix-cert-reload.sh) ради сертификатов, никакой новой машинерии. - - **Проверено на живом 1.0.0 до принятия решения:** Postfix 3.7.11 (команда `postfix logrotate` есть начиная с 3.4, её реализация в `postfix-script` — это `mv` + `master -t || kill -HUP` + `sleep 1` + компрессор); postlogd работает под uid `postfix`, `/var/log` принадлежит root, `/var/log/mail.log` — `root:root 0644` и пользователю `postfix` на запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона. - - **Стендовая проверка при реализации (обязательная по плану) обнаружила, что предположение о `nocreate` неверно.** На живом контейнере (`selfpost.example.com`, тестовый образ) после `mv` + `postfix reload` файл действительно появляется — но не сразу и не на `0644`: сам HUP лог не пересоздаёт, это происходит лениво при следующей фактической записи, и создаётся он с режимом `0600` — непривилегированная панель (свой uid) такой файл читать не может, то есть просмотр `mail.log` в панели остаётся сломан до следующего холодного старта контейнера (там `0644` берётся из другого, не связанного с этим, пути создания). Решение — то, что план заранее указал как запасной вариант: `create 0644 root root` вместо `nocreate`. logrotate создаёт пустой файл на 644 сразу после `mv`, ещё до запуска `postrotate`, и Postfix при следующей записи просто открывает и дозаписывает уже существующий файл, не трогая его режим. Проверено многократно на стенде: после ротации файл сразу (без окна) читаем непривилегированным uid панели, и остаётся на 644 после того, как в него попадает новый трафик. - - **Смежное, не решённое (тот же класс потерь, вариантом выше не лечится):** при рестарте панели `follow()` стартует с конца файла, поэтому строки, записанные пока она не читала, пропускаются; при редеплое `mail.log` исчезает вместе с контейнером — `/var/log` не в volume. В обоих случаях статусы писем, бывших в полёте, остаются `queued` навсегда — вероятно, чаще, чем при ротации. Кандидаты, если решим закрывать: переживать рестарт (запоминать позицию), вынести лог в `/data`, либо досверять зависшие строки по `postqueue`. -3. **Поведение при незаданном `SELFPOST_HOSTNAME` — решено: фатальная проверка в [entrypoint.sh](../build/entrypoint.sh) с развёрнутым текстом ошибки.** Прежнее предложение («предупреждать громко в лог, но не падать») отклонено: оба отказа мягкого fallback'а тихие и отложенные, а предупреждение в лог для таких отказов не работает — оно печатается при старте, а последствие проявляется через часы и в другом месте. - - **Что на самом деле ломает мягкий fallback** (формулировка «падают в `localhost`» занижала проблему — два fallback'а расходятся между собой): - - панель берёт realm как `SASL_REALM` → `SELFPOST_HOSTNAME` → **`localhost`** ([main.go:128](../cmd/panel/main.go:128)), а `postfix-config.sh` берёт `myhostname` как `SELFPOST_HOSTNAME` → **`hostname -f`**, то есть ID контейнера ([postfix-config.sh:21](../build/postfix-config.sh:21)). При пустом `smtpd_sasl_local_domain` Cyrus резолвит голый логин против realm = `myhostname` (механика проверена на стенде в Фазе 5), значит аккаунты пишутся в один realm, а ищутся в другом → **аутентификация не работает ни для одного приложения**, при полностью зелёной панели, а видно это только как `535` в чужом приложении. Само расхождение выведено из проверенной механики, отдельно не воспроизводилось; - - `EHLO` = ID контейнера: не FQDN, не совпадает с PTR, SPF-проверка HELO падает → почта, если она всё же уходит, попадает в спам. Отказ, не видимый вообще нигде; - - побочная ловушка того же класса: `SASL_REALM` читает только панель, `postfix-config.sh` про него не знает — задать один `SASL_REALM` без `SELFPOST_HOSTNAME` ломает так же. - - **Почему фатально, а не мягко.** Это не настройка с разумным умолчанием, а идентичность, обязанная одновременно совпасть с PTR/rDNS, с CN/SAN сертификата и с SASL-realm (ТЗ 5.2 п.3, 8) — значения, удовлетворяющего всем трём, угадать нельзя, поэтому **любой** fallback заведомо неверен. Ужесточением это не является: штатный деплой уже требует переменную (`${SELFPOST_HOSTNAME:?...}`, [docker-compose.yml:27](../deploy/docker-compose.yml:27)), CI контейнер не поднимает ([test.yml](../.github/workflows/test.yml) — только `vet`/`test`), а локальный запуск стоит одной явной переменной (`SELFPOST_HOSTNAME=localhost`). Меняется поведение только вне поставляемого compose (`docker run`, k8s, свой compose). - - **Правки:** - - `entrypoint.sh`: проверка до `postfix-config.sh` и до `supervisord`; при пустом значении — `exit 1`. Текст ошибки развёрнутый, а не `SELFPOST_HOSTNAME is required`: что это за имя, почему обязательно (PTR + CN/SAN + SASL realm), пример значения, где задаётся (`.env`). Это и есть замена «баннера в панели» — объяснительность там, где её реально прочитают (вывод `docker compose up`); - - там же — синтаксическая проверка значения: минимум одна точка, без схемы, порта и пробелов. Ловит типовые `https://mail.example.com` и `mail.example.com:465`, которые realm не ломают (обе стороны берут одну переменную), но ломают HELO и совпадение с сертификатом, то есть дают тот же тихий спам-отказ; - - `saslRealm()` ([main.go:128](../cmd/panel/main.go:128)) и fallback в `postfix-config.sh` **оставляем как есть**: после гейта в контейнере эти ветки мертвы, а вне контейнера (запуск бинаря локально, без Postfix) расходиться не с чем. Существующие «(SELFPOST_HOSTNAME is not set)» на статус-странице и `PTR: unknown` ([server.go:21](../internal/dnscheck/server.go:21)) тоже остаются — они как раз про этот случай. - - **Почему отклонён вариант «панель поднимается с баннером, фатально только для почтового тракта»** (обсуждался как более мягкий): - - он отменяет инвариант Фазы 4 — listener `crashexit` роняет весь контейнер, когда любой managed-процесс уходит в FATAL, именно чтобы не оставался «живой контейнер с мёртвым компонентом» ([crashexit.py](../build/crashexit.py)). В наивной реализации он в фатальный вариант и вырождается, только на минуту позже и с тремя циклами ретраев в логе; - - у него нет обычного оправдания degraded-режима — «починить на живую». Для TLS-сертификата degraded-режим выбран сознательно (файл можно доложить в mount, `cert-reload` подхватит без рестарта), а hostname вшивается в `myhostname` при генерации конфига, поэтому лечение всё равно = правка `.env` + пересоздание контейнера; - - главное: в таком состоянии панель остаётся полноценным писателем состояния, и состояние будет неверным. Созданные аккаунты лягут в realm `localhost`, а после задания hostname и рестарта `Secret()` и `Delete` ищут по паре (login, realm) ([sasl.go:97](../internal/app/sasl.go:97), [sasl.go:67](../internal/app/sasl.go:67)) → экспорт их не видит, удаление приложения оставляет сироту в `sasldb2` навсегда, а в панели они выглядят существующими. Чтобы это было безопасно, пришлось бы ещё блокировать записывающие действия — третий режим работы вместо одной проверки; - - канал доставки баннера испорчен ровно тем условием, о котором он предупреждает: адрес панели на первом запуске берётся из setup-ссылки, а она в этом сценарии печатается как `https://localhost/setup/` ([setup.go:124](../internal/web/setup.go:124)); - - `HEALTHCHECK` в образе не объявлен, поэтому «панель жива, почта мертва» для внешнего мониторинга выглядит здоровым контейнером, а crash-loop виден любой системе. - - **Цена:** контейнер без `SELFPOST_HOSTNAME` не стартует — это и есть цель. **Проверка на стенде при реализации:** контейнер без переменной падает с ожидаемым текстом и не уходит в бесконечный тихий retry; обычный деплой из [deploy/docker-compose.yml](../deploy/docker-compose.yml) не меняется; значение с портом/схемой отклоняется. Обязательность отразить в [README](../README.md) и `deploy/.env.example` (там переменная уже первая в списке). - -### C. CI и тесты - -4. **Интеграционный e2e — решено: герметичный контейнерный прогон отдельным Go-модулем, гейт перед публикацией образа.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории; автоматизируется по сути тот же сценарий. - - **Что закрывается.** Не «интеграция вообще», а один класс отказов — **обвязка контейнера**, невидимая для `go test`: все 22 тестовых файла фейкуют границу процесса (`saslpasswd2` подменён хуком `s.run` — [sasl_test.go](../internal/app/sasl_test.go), milter гоняется против `fakeRecorder`, web — через `httptest`), реальные Postfix/OpenDKIM/supervisord/sasldb2 не стартуют нигде. Исторически ломалось ровно здесь: chroot ломал DNS ([postfix-config.sh:168](../build/postfix-config.sh:168)), Postfix не доставал до milter-сокетов (общая группа + setgid, [entrypoint.sh:62](../build/entrypoint.sh:62)), расходился SASL-realm (`535` для всех приложений), reload Postfix сигналом не работал (потребовалась one-shot program), а недостаточный `cap_add` в [docker-compose.yml](../deploy/docker-compose.yml) уронил контейнер **в проде**. Общее свойство всех пяти: панель зелёная, юнит-тесты зелёные, тракт мёртв. - - **Что автоматизировать нельзя и не пытаемся:** реальные PTR/rDNS, сертификат LE, репутация IP, доставка во внешний ящик (исходящий 25 на GitHub-раннерах закрыт). Это остаётся ручной проверкой на проде. - - **Форма:** - - `test/e2e/` — **отдельный Go-модуль со своим `go.mod`**: `go test ./...` не подхватывает его без build-тегов, а тестовые зависимости (проверка DKIM-подписи и прочее) не попадают в граф основного модуля, где сейчас три прямых зависимости; - - стенд — **поставляемый [deploy/docker-compose.yml](../deploy/docker-compose.yml) плюс override**, а не отдельный тестовый compose: иначе `cap_drop`/`cap_add`/`no-new-privileges` — ровно то, что сломалось в проде, — останутся непроверенными. Override задаёт самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, тестовый `SELFPOST_HOSTNAME`, заниженный `RATE_LIMIT_MESSAGES_PER_IP`, резолвер (`dns:`) и **высокие порты вместо 465/587/8080** — на dev-сервере они заняты продом, и без этого `make e2e` там падал бы на конфликте портов, работая при этом в CI; - - герметичная почта: фиктивная DNS-зона (CoreDNS/dnsmasq) + sink-MX на `smtp-sink` из пакета postfix — новых зависимостей ноль. DKIM-запись тест **берёт из панели и сам публикует в зону**, поэтому попутно проверяется, что запись, которую панель печатает пользователю, вообще рабочая; - - детерминизм обязателен: никаких `sleep N`, только опрос с таймаутом. Плавающий гейт перестают чинить, и тогда он хуже отсутствующего. - - **Объём проверок.** Позитив: старт контейнера (автостартующие `opendkim`/`panel`/`postfix`/`cert-reload`/`logrotate` в `RUNNING`, `postfix-reload` — `NOT STARTED`, он `autostart=false`) → setup по токену из `/data/setup-token` → login → домен → приложение → SMTP AUTH на 465 → письмо доставлено на sink → подпись проверяется против ключа из зоны → строка журнала переходит `queued → sent` (это же покрывает `logtail`, а после B.2 — и ротацию). Негативы: - 1. отправка без AUTH — отказ; - 2. чужой отправитель при валидном AUTH — отказ (`reject_sender_login_mismatch`, ТЗ 5.1 п.3); - 3. relay на чужой домен — отказ (`reject_unauth_destination`), прямая проверка «не open relay»; - 4. L1-лимит по IP (заниженный в override) — отказ после исчерпания; - 5. L2-лимит, выставленный **через панель**, с записью `rejected` — заодно путь панель→БД→milter; - 6. fail-open journal-milter'а: `supervisorctl stop panel` → письмо всё равно принято, контейнер жив. Это выполнимо, потому что `crashexit` подписан только на `PROCESS_STATE_FATAL` ([supervisord.conf:122](../build/supervisord.conf:122)), а штатный stop даёт `STOPPED`; - 7. пустой и синтаксически неверный `SELFPOST_HOSTNAME` — контейнер падает с ожидаемым текстом (проверка из B.3); - 8. сессия переживает `docker restart` (проверка из B.1). - - **Вне объёма:** «зависший», а не упавший milter — требует подставного сокета внутри контейнера, это уже chaos-тест ради одного таймаута. - - **Запуск.** Основной путь — `make e2e` на dev-сервере перед тегированием (там и так идёт вся сборка) плюс `workflow_dispatch`. В CI прогон вешается **на тег `vX.Y.Z` и блокирует публикацию образа**; на обычный push не вешается — `vet`/`test` в [test.yml](../.github/workflows/test.yml) остаются как есть. Уведомления специально не настраиваются: признак провала — отсутствие образа в `ghcr` после тега, смотрится вкладкой Actions. - - **Переработка [release.yml](../.github/workflows/release.yml)** — следствие требования покрыть обе архитектуры. Сейчас multi-arch собирается через qemu; гонять под эмуляцией полный стек Postfix мучительно долго, поэтому релиз переезжает на нативную сборку по архитектурам: job `prepare` (единственная точка деривации версии из тега — на ней держится инвариант ТЗ 7.5.А) → матрица `[ubuntu-latest, ubuntu-24.04-arm]`, в каждой сборка `--load` → e2e → push per-arch тега `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge`: `docker buildx imagetools create -t …:X.Y.Z`. `setup-qemu-action` уходит, `provenance: false` сохраняется. Порядок «сначала тест, потом push» выбран ради того, чтобы публиковались **ровно те байты, которые прогонялись**; альтернатива (push по digest → тест → сборка манифеста) даёт ту же гарантию, но оставляет в registry мусорные untagged-манифесты после красного прогона и требует второго пути для `workflow_dispatch`. Per-arch теги остаются в registry побочным продуктом; неизменяемость версионного тега (ТЗ 10.1) это не нарушает. Бесплатные arm-раннеры доступны, потому что зеркало `github.com/mixeme/selfpost` публичное. - - **Цена:** ~10–15 минут на релиз; переработка релизного workflow, который сейчас работает; новый модуль и compose-override на сопровождении. **Проверка при реализации:** `make e2e` на dev-сервере проходит, не задевая прод-порты; красный e2e действительно не даёт опубликовать образ — проверяется одноразовым тегом на заведомо сломанном прогоне (тег и per-arch пакеты после проверки удалить). - - **Порядок работ:** сначала B.1–B.3 (они полностью специфицированы, иначе харнесс пришлось бы переписывать под них), затем харнесс — и стендовые проверки B.1/B.3 переезжают в него постоянными регрессиями (пункты 7–8 выше), а не выбрасываются после однократного прогона. - -### D. Ревизия безопасности - -5. **Проверка на уязвимости моделью Fable — решено: отдельный проход после B.1–B.3 и C.4, до тега релиза.** - - **Почему после всех четырёх, а не по ходу каждого.** Каждый пункт трогает ровно ту поверхность, которую аудит ТЗ 7.6 на v1.0 видел в другом виде: B.1 переписывает аутентификацию (сессии в SQLite, SHA-256 от токена, скользящее продление, разлогин остальных сессий при смене пароля — текущая остаётся), B.2 меняет обращение с дескриптором лога и вешает `postfix reload` на logrotate, B.3 добавляет разбор значения переменной в shell до старта supervisord, C.4 приносит переработанный релизный workflow и compose-override с **сознательно ослабленными** настройками (`PANEL_COOKIE_SECURE=false`, самоподписанный сертификат, заниженные лимиты), которому нельзя утечь в прод. Ревизия по пунктам дала бы четыре среза, а смотреть надо итоговое состояние — и заведомо один раз, а не четыре. - - **Объём.** Диф от тега `v1.0.0` до состояния перед следующим тегом целиком — то есть вместе с Фазами 12–14, которых в аудите v1.0 не было, — плюс повторный проход по чек-листу ТЗ 7.6, а не только по изменённым строкам: регресс в 7.6 возможен и в нетронутом коде, если рядом поменялся вызывающий. Приоритет задаёт то, что панель публично доступна (ТЗ 2.4): аутентификация и сессии, валидация ввода, запись в конфиги и map-файлы (injection), `os/exec` без shell, права на файлы в `/data`, обращение с секретами (пароли приложений, `sasldb2`, архив бэкапа). - - **Модель — Fable, и это сознательно не Opus:** B и C пишет Opus, а проверка собственной работы систематически слабее независимой. Правило progress.md «безопасность/инфра → Opus» этим не отменяется — оно про написание кода, здесь речь про ревизию. Форма прогона: `/security-review` по изменениям, пока они ещё в ветке (скилл смотрит диф), плюс отдельный ручной проход по 7.6 целиком. - - **Что с находками.** Каждая закрывается явно: правка до тега либо запись в [security.md](security.md) как принятый риск с обоснованием (там уже два таких). «Посмотрели и ладно» закрытием не считается. **Гейт:** вместе с e2e из C.4 — до тега; находка класса «эксплуатируется снаружи» тег откладывает. - -### E. Указатель на объём 2.x - -6. **Входящий релей, pluggable-антиспам и роль администратора домена** вынесены целиком в [roadmap.md](roadmap.md) (линия 2.x.x, вне v1.0/v1.x, только по согласованию — ТЗ 12.6). Здесь оставлен лишь этот пункт-напоминание, что это **сознательно отложенный объём**, а не забытый. +**Гейт.** Вместе с e2e (C.4, готов): до тега. Находка класса «эксплуатируется +снаружи» откладывает тег. Каждая находка закрывается явно: правка до тега либо +запись в [security.md](security.md) как принятый риск с обоснованием. «Посмотрели +и ладно» — не закрытие. diff --git a/docs/progress.md b/docs/progress.md index 2400dc8..dce47f8 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -11,7 +11,7 @@ ## Как возобновить после сброса контекста 1. Прочитать этот файл (текущее состояние, что дальше). -2. Открыть `implementation-plan.md` — там нерешённые вопросы для v1.0/v1.x; линия 2.x.x (Фаза O1+, роль администратора домена) — в `roadmap.md`; принятые риски безопасности — в `security.md`. +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. Продолжить с пункта «Следующий шаг». @@ -19,7 +19,9 @@ Правило: безопасность / инфра / риск-критичное → **Opus**; UI / документация / бойлерплейт → **Sonnet**; тривиальная механика → **Haiku**. -Исключение — **ревизия** (не написание) кода: предрелизная проверка на уязвимости (пункт **D.5** плана) делается моделью **Fable**, чтобы проверял не тот, кто писал. +Исключение — **ревизия** (не написание) кода: предрелизная проверка на уязвимости +([implementation-plan.md](implementation-plan.md) § D) делается моделью **Fable**, +чтобы проверял не тот, кто писал. ## Коммиты @@ -45,8 +47,8 @@ - **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.example.com`, отдельный образ `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.example.com`)**: `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 (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, требует согласования). +- **Дальше:** [implementation-plan.md](implementation-plan.md) § D — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (готов) это гейт перед тегом релиза. +- **Принятые риски** — [security.md](security.md). **Опционально v1.x / 2.x** — [roadmap.md](roadmap.md) (хвост документации, send-log gaps, Фаза O1+, роль администратора домена). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). ## Рабочая петля (dev loop) — ВАЖНО diff --git a/docs/roadmap.md b/docs/roadmap.md index 1cbb6e6..b41f440 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,6 +37,13 @@ Codeberg (`codeberg.org/mix/selfpost/raw/branch/main/deploy/...`). **Готово, когда:** Quick start указывает на Codeberg; тег образа в compose совпадает с релизом; `docs/logo` либо содержит файлы, либо отсутствует. +**Send-log vs `mail.log` (опционально).** После рестарта панели или пересоздания +контейнера строки send-log могут навсегда остаться `queued` — log-tailer не +дочитывает пропущенный хвост, `mail.log` не в `/data`. Rename-ротация (B.2) +это не лечит. Кандидаты, если станет больно: persist позиции чтения, volume для +лога, сверка зависших строк через `postqueue`. As-built и принятый риск: +[architecture.md](architecture.md) § Log tailer, [security.md](security.md). + --- ## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин diff --git a/docs/security.md b/docs/security.md index f98e960..2e3e586 100644 --- a/docs/security.md +++ b/docs/security.md @@ -89,10 +89,18 @@ Hardening сверх обязательного (security-заголовки, п панели, отправит запрос сам — против этого работают автоэкранирование `html/template` и CSP, поэтому шаблоны не должны содержать inline-скриптов и inline-стилей. +- **Восстановление старого полного бэкапа возвращает строки сессий из архива.** + Сессия, разлогиненная уже после снятия бэкапа, может снова стать действительной, + если браузер всё ещё хранит cookie и idle-срок не истёк. См. [architecture.md](architecture.md) + § Sessions. +- **Send-log может навсегда остаться `queued` после рестарта панели или + пересоздания контейнера.** Log-tailer стартует с конца `mail.log`; файл не в + `/data` и теряется при recreate. Rename-ротация это не лечит. См. [architecture.md](architecture.md) + § Log tailer — known gaps. ## Как этот список пополняется -Предрелизная проверка на уязвимости (пункт **D.5** плана, модель Fable) закрывает -каждую находку одним из двух способов: правка до тега — либо запись сюда, с -обоснованием и условием возврата, как у двух пунктов выше. Третьего варианта -(«посмотрели и ладно») нет. +Предрелизная проверка на уязвимости ([implementation-plan.md](implementation-plan.md) +§ D, модель Fable) закрывает каждую находку одним из двух способов: правка до +тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше. +Третьего варианта («посмотрели и ладно») нет.