Files
selfpost/docs/progress.md
T
mix 6a7d010868 feat: optional password encryption for backup and domain export (code-review.md § Phase 1.5)
Both secret-bearing downloads can now be sealed with a password. Unticked, the
forms produce exactly the files they did before.

- internal/secretfile: envelope format — magic/type/scrypt params/salt/nonce
  prefix header, then 64 KiB AES-256-GCM chunks each authenticated with the
  header, its counter and an end-of-stream flag, so truncation, reordering and
  tampering fail to open instead of restoring a plausible prefix. Streams both
  ways, so a full backup never sits in memory.
- Panel: "Encrypt with a password" checkbox on the full-backup and
  domain-export forms (shared partial, toggled from panel.js — no inline
  script); domain import detects an encrypted export by magic bytes, not by
  extension, and asks for the password.
- selfpost-backup: writes .spbk when given a password and converts one back
  with -decrypt, which a restore needs. The password comes from
  SELFPOST_BACKUP_PASSWORD or -password-file, never argv.
- Docs: README, security.md (+ accepted risk: encryption stays opt-in),
  architecture.md, progress.md, CHANGELOG.

Verified locally: panel-encrypted archive decrypts through the CLI and unpacks;
wrong password and password mismatch are refused; UI checked in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:43:28 +03:00

62 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Прогресс реализации 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).
Рецензирование кодовой базы и план доработок: [code-review.md](code-review.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.1C.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.example.com`, отдельный контейнер `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.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` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `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 — хвост документации и деплоя».
- **Рецензирование кодовой базы** (2026-08-05): [code-review.md](code-review.md) — 10 разделов (архитектура, качество, docs, GUI, legacy, риски), приоритизированный план реализации и маршрутизация моделей. Критичных багов не найдено; блокер релиза — § D ниже.
- **§ 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)** ([code-review.md](code-review.md) § Фаза 1 — 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 ./...` чистые в обоих модулях (dev-server).
- **Фаза 1.5 выполнена (2026-08-06)** ([code-review.md](code-review.md) § Фаза 1.5 — шифрование резервных копий, 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) — кандидат при следующем прогоне на dev-сервере.
- **Дальше:** релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа + Codeberg URL, git tag) по явной команде пользователя. Остальные пункты из [code-review.md](code-review.md) — polish (фазы 23).
- **Принятые риски** — [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) — ВАЖНО
Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → `go build`/`go vet`/`docker build`/тесты там. `rsync` в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: `tar -czf - --exclude=.git . | ssh root@selfpost.example.com 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'`; Go на сервере — в `/usr/local/go/bin` (не в `PATH` по умолчанию); образ — `docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .`. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).