docs: remove code-review.md, carry the open items into roadmap

The review's plan is finished — phase 0 (bar the two release-commit steps),
1, 1.5, 2 and 3 are all closed — and what remained in the document was a second
copy of things that already live in architecture.md, security.md, roadmap.md or
the code comments: the GUI compromise table is in panel.css/panel.js/
middleware.go/handlers_auth.go, the single SQLite connection and the dual
cookie names are explained where they are implemented, the accepted gaps are in
security.md, and the model-routing table names progress.md and development.md
as its own source. A second copy of a fact is a place for it to go stale.

Four items were genuinely open and had no other home, so they moved to
roadmap.md rather than disappearing:

- splitting internal/web into subpackages (2.x) — with the reason to wait: the
  flat package still reads at 47 files, and both 2.x features grow it, so the
  cut is worth making before that growth, not now;
- a consolidated documentation index in the README (v1.x tail);
- the adaptive polling interval for a tab that is visible but idle — the hidden
  case is already handled, and the remainder is explicitly allowed to end as
  "decided not to";
- CONTRIBUTING.md, already moved to 2.x in the previous commit.

References retargeted: progress.md (7), roadmap.md (5), implementation-plan.md
(1). The CHANGELOG entries that cite the document are left as written — they
describe what happened at the time. The review text stays in git history at
aaf0711.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:22:52 +03:00
parent c0d9aa7518
commit 0e29acb955
5 changed files with 69 additions and 498 deletions
+8
View File
@@ -44,6 +44,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
live document that owns the subject — `docs/architecture.md` (with section), live document that owns the subject — `docs/architecture.md` (with section),
`docs/product.md`, `docs/security.md`, or the README. Comments only; no `docs/product.md`, `docs/security.md`, or the README. Comments only; no
behaviour is affected. behaviour is affected.
- `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the
release-commit steps), 1, 1.5, 2 and 3 are all closed — and the rest of the
document had become a second copy of what `architecture.md`, `security.md`
and the code comments already say. What was genuinely open moved to
`docs/roadmap.md`: splitting `internal/web` into subpackages, a consolidated
documentation index in the README, the adaptive polling interval for an idle
but visible tab, and `CONTRIBUTING.md`. The review text stays in git history
(`522425a`); the CHANGELOG entries below that cite it are left as written.
- `docs/architecture.md` gained a *Code layers* section: a diagram of - `docs/architecture.md` gained a *Code layers* section: a diagram of
handlers → services → store plus the adapters, and the reason the services handlers → services → store plus the adapters, and the reason the services
layer exists (multi-store writes and their rollback) — closing item A2 of layer exists (multi-store writes and their rollback) — closing item A2 of
-472
View File
@@ -1,472 +0,0 @@
# Рецензирование кодовой базы SelfPost
**Дата:** 2026-08-05
**Объём ревью:** ~172 файла, 64 Go-исходника (~10 770 строк в `internal/` + `cmd/`), 29 unit-тестов, e2e-модуль `test/e2e/`, 17 HTML-шаблонов, 15 doc-файлов.
Связанные документы: [architecture.md](architecture.md), [security.md](security.md), [implementation-plan.md](implementation-plan.md), [progress.md](progress.md), [roadmap.md](roadmap.md).
---
## Общая оценка
| Критерий | Оценка | Комментарий |
|----------|--------|-------------|
| Архитектура | **Отлично** | Чёткое слоение, минимум связности |
| Сложность vs масштаб | **Отлично** | Без over-engineering |
| Качество кода | **Отлично** | Идиоматичный Go, продуманные rollback-пути |
| Документация | **Хорошо** | As-built docs точны; есть RU/EN split и stale comments |
| Поддерживаемость | **Хорошо** | Высокий порог входа из-за phase-комментариев |
| Логические ошибки | **Минимально** | Критичных багов не найдено; есть принятые операционные gap'ы |
| Legacy | **Низкий** | Только архив spec + phase-комментарии |
| GUI | **Хорошо** | Нет «костылей»; осознанные CSP/layout компромиссы |
**Вывод:** проект готов к релизному тегу после закрытия § D ([implementation-plan.md](implementation-plan.md)) — предрелизного security review. Остальное — polish, не блокеры.
**Статус на 2026-08-06:** § D закрыт, фазы 1, 1.5, 2 и 3 выполнены, добор по §§ 1/3/4 сделан. Незакрытым остаётся только то, что делается в момент резки версии: бамп тега образа в compose и сам git-тег ([roadmap.md](roadmap.md) § v1.x).
---
## 1. Архитектура и структура проекта
### Текущая структура
```mermaid
flowchart TB
subgraph cmd [cmd]
panel["panel (HTTP + milter + logtail)"]
backup["selfpost-backup CLI"]
end
subgraph web [internal/web — 3030 LOC]
handlers["handlers_*.go"]
templates["templates/*.html"]
security["security.go, session.go"]
end
subgraph services [Services]
domainSvc["internal/domain"]
appSvc["internal/app"]
end
subgraph persistence [Persistence]
store["internal/store (SQLite)"]
end
subgraph adapters [Adapters]
postfix["internal/postfix"]
milterPkg["internal/milter"]
logtail["internal/logtail"]
dnscheck["internal/dnscheck"]
backupPkg["internal/backup"]
health["internal/health"]
end
panel --> web
panel --> milterPkg
panel --> logtail
web --> domainSvc
web --> appSvc
domainSvc --> store
appSvc --> store
milterPkg --> store
logtail --> store
domainSvc --> postfix
appSvc --> postfix
```
### Сильные стороны
- **Layered / ports-and-adapters:** handlers → services (`domain`, `app`) → `store`; инфраструктура изолирована в адаптерах ([`internal/web/web.go`](../internal/web/web.go), [`internal/app/service.go`](../internal/app/service.go)).
- **Composition root** в [`cmd/panel/main.go`](../cmd/panel/main.go): три роли (HTTP, journal-milter, log-tailer) в одном процессе — оправдано для single-container deployment.
- **Interface seams** для тестов: `milter.Store`, `app.SenderMaps`, `logtail.StatusStore`.
- **Embedded migrations** ([`internal/store/store.go`](../internal/store/store.go)) — простой, надёжный подход для 3 миграций.
- **E2E как отдельный модуль** (`test/e2e/go.mod`) — не загрязняет основной модуль.
### Замечания (не блокеры)
- **`internal/web/` — 47 файлов, 3030 строк** — самый крупный пакет. При росте v2.x (роли, inbound relay) стоит выделить подпакеты (`web/handlers`, `web/auth`) или split по доменам. Сейчас — приемлемо.
- **Один SQLite connection** (`MaxOpenConns(1)`) — сознательный trade-off; при росте нагрузки milter + HTTP + logtail будут сериализованы. Документировано, для single-admin panel — норма.
### Рекомендации
| # | Действие | Приоритет |
|---|----------|-----------|
| A1 | Оставить текущую структуру; рефакторинг пакетов — только при старте 2.x | Низкий |
| A2 | Добавить в [architecture.md](architecture.md) диаграмму слоёв (как выше) — **выполнено** (§ Code layers) | Низкий |
**Модель:** Sonnet (документация)
---
## 2. Соответствие сложности масштабу проекта
### Факты
- Outbound SMTP relay + admin panel для одного оператора.
- ~10.7K строк Go, 3 зависимости (`go-milter`, `x/crypto`, `modernc.org/sqlite`).
- Нет ORM, нет SPA, нет message queue — всё уместно.
### Сильные стороны
- Нет лишних абстракций (нет generic repository, нет DI-фреймворка).
- Сервисный слой тонкий, но достаточный: координация SQLite + sasldb2 + Postfix maps ([`internal/app/service.go`](../internal/app/service.go)).
- Fail-open/fail-closed решения явно задокументированы (OpenDKIM tempfail vs journal fail-open).
### Замечания
- **Rate limiting в двух местах** (Postfix anvil L1 + milter L2) — сложность оправдана спецификацией, но требует понимания оператором.
- **Backup с manifest version gate** ([`internal/backup/backup.go`](../internal/backup/backup.go)) — чуть тяжелее минимума, но оправдано для migration safety.
**Вердикт:** сложность **адекватна** масштабу. Over-engineering не обнаружен.
---
## 3. Качество написанного кода
### Сильные стороны
- **Комментарии объясняют «почему»**, не «что» — образцовый уровень ([`internal/web/security.go`](../internal/web/security.go), [`internal/milter/milter.go`](../internal/milter/milter.go)).
- **Rollback-паттерны** при partial failure ([`internal/app/service.go`](../internal/app/service.go) `rollbackCreate`).
- **Ordering guarantees:** SQLite row before SASL write — защита от race и password clobber.
- **Validation centralized:** [`internal/web/validate.go`](../internal/web/validate.go), [`internal/app/validate.go`](../internal/app/validate.go).
- **Atomic file writes** для конфигов ([`internal/postfix/write.go`](../internal/postfix/write.go), [`internal/domain/dkim.go`](../internal/domain/dkim.go)).
- **Test coverage ~45%** file ratio (29 test / 64 source files); ключевые пути покрыты (auth, sessions, milter, backup, dnscheck).
### Замечания
| Файл | Замечание | Severity |
|------|-----------|----------|
| [`internal/web/token.go`](../internal/web/token.go) | `panic` при сбое `crypto/rand` — осознанно, документировано | Info |
| [`internal/web/handlers_domains.go`](../internal/web/handlers_domains.go) | Stale comment: «Applications and send log arrive in later phases» — **закрыто** (Фаза 1) | Low |
| Phase/spec references | ~50+ файлов с «Phase N», «spec 7.x» — **закрыто**: «Phase N» в Фазе 1, ссылки на архивную спецификацию — отдельным проходом (см. § 4) | Low |
| [`cmd/panel/main.go`](../cmd/panel/main.go) | Package comment всё ещё упоминает «Phase 1 stubs» — **закрыто** (Фаза 1) | Low |
### Потенциальные улучшения качества
- Единый проход **gofmt + удаление stale phase-комментариев** (механическая работа) — **выполнено** (Фаза 1; `gofmt -l` теперь и в CI).
- Добавить **table-driven test** для edge cases в `parseDelivery` (exotic Postfix status values) — **выполнено**, и проход оказался не косметическим: он вскрыл реальный баг. Шаблон разбора брал `status=` жадно, то есть **последнее** вхождение в строке, а Postfix дописывает в конец ответ удалённого сервера дословно. Отказ, в тексте ответа которого встречалось `status=sent`, попадал в журнал как доставленный. Исправлено на ленивый разбор (первое `status=` после получателя).
**Модель:** Haiku (механическая чистка комментариев), Sonnet (точечные правки)
---
## 4. Полнота документации и соответствие коду
### Сильные стороны
- **[architecture.md](architecture.md)** — as-built source of truth; маршруты, процессы, persistence совпадают с кодом.
- **[security.md](security.md)** — чеклист + принятые риски; каждый риск привязан к коду.
- **[product.md](product.md)** — границы v1.0/out-of-scope чёткие.
- **Regression guard:** [`cmd/panel/envdoc_test.go`](../cmd/panel/envdoc_test.go) — env vars в README = `loadConfig`.
- **CHANGELOG** в формате Keep a Changelog.
### Расхождения docs ↔ code
| Проблема | Где | Реальность |
|----------|-----|------------|
| Image tag `0.1.0` vs «v1.0» | [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) vs README | **Открыто:** roadmap § v1.x — bump в релизном коммите вместе с git-тегом |
| Quick start URLs | README | **Закрыто:** Codeberg уходит как публичная площадка, единственный дом проекта — GitHub; вместе с URL переехал и путь Go-модуля (`github.com/mixeme/selfpost`) |
| `docs/logo` | roadmap | **Закрыто** (Фаза 1): каталог отсутствует, критерию удовлетворяет |
| `docs/specification.md` | documentation-plan D9 | **Закрыто:** файл остаётся в `docs/archive/` как история, но ссылок на него из кода больше нет — все «spec N.x» заменены на живые документы |
| Phase language в коде | 50+ файлов | **Закрыто** (Фаза 1) |
| RU/EN split | progress, roadmap, implementation-plan (RU) vs README/architecture (EN) | Намеренно, но барьер для EN-only contributors; снимается вместе с `CONTRIBUTING.md` — перенесено в [roadmap.md](roadmap.md) § 2.x |
### Комментирование кода
- **Высокое качество** в security-critical paths.
- **Среднее** в CRUD handlers (делегируют в services — acceptable).
- **Выполнено:** ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», но и «spec 4/5/6/8/9», которые страдали ровно тем же (указывали в документ, помеченный «не источник истины»). Каждая заменена на живой документ, владеющий темой: [architecture.md](architecture.md) с указанием секции, [product.md](product.md), [security.md](security.md) или README. Секция указывается там, где документ большой (architecture.md, README); для короткого `product.md` — только файл.
**Модель:** Sonnet (docs sync)
---
## 5. Читаемость и поддерживаемость
### Для кого код читаем
- **Go-разработчик со знанием SMTP/Postfix** — да, без проблем.
- **Новичок без почтового бэкграунда** — потребуется [architecture.md](architecture.md) + README.
### Факторы, помогающие поддержке
- Предсказуемая структура handler → service → store.
- Embedded templates (`//go:embed`) — один binary, нет внешних assets.
- Makefile targets: `vet`, `test`, `build`, `e2e`.
- E2E suite покрывает happy path + negatives.
### Факторы, затрудняющие поддержку
- Phase-номера в комментариях без контекста.
- Dual cookie names (`__Host-` vs plain) — хорошо документировано, но неочевидно.
- HTMX fragment polling — нужно понимать SSR + partial updates.
- Dev loop: Windows local edit → SSH to Debian server ([progress.md](progress.md)) — не стандартный `go run`.
### Рекомендации
| # | Действие | Модель |
|---|----------|--------|
| M1 | Cleanup phase-комментариев → «as-built» language | Haiku |
| M2 | Добавить `CONTRIBUTING.md` (dev loop, model routing, commit protocol) — опционально v1.x | Sonnet |
| M3 | Consolidated doc index в README (ссылки на все docs/) | Sonnet |
---
## 6. Логические ошибки и риски
### Критичных багов не обнаружено
E2E покрывает: bootstrap, SMTP AUTH, DKIM, send-log lifecycle, negatives (relay, sender mismatch, L1/L2 limits, milter fail-open, hostname gate, session survive restart).
### Принятые операционные gap'ы (не баги, но важно знать)
| Gap | Описание | Документировано |
|-----|----------|-----------------|
| Send-log `queued` forever | **Закрыто для рестарта:** Фаза 3 — offset персистится (`logtail_state`), хвост дочитывается. Остаётся пересоздание контейнера: `mail.log` не в `/data` | [security.md](security.md), [roadmap.md](roadmap.md) |
| CSRF without tokens | POST без Origin/Sec-Fetch-Site пропускается | [security.md](security.md) |
| Fail-open L2 rate limit | DB error → mail проходит | [`internal/milter/ratelimit.go`](../internal/milter/ratelimit.go) |
| Shallow SPF check | Не следует `include:`/`redirect=` | README, `internal/dnscheck/spf.go` |
| Plaintext backup/export at rest | DKIM-ключи, SASL, пароли приложений в cleartext `.tar.gz`/`.json` | **Закрыто:** R13 — опциональное шифрование (`.spbk`/`.spde`); открытый вариант остаётся умолчанием, риск переформулирован в [security.md](security.md) |
**Не риск (решение оператора):** «Session resurrection from backup» — снято из [security.md](security.md).
### Потенциальные логические нюансы (низкий приоритет)
1. **Rate limit race:** `CountMessages` + `InsertQueued` не в одной транзакции — при высокой нагрузке возможен overshoot на 1 сообщение. Для differentiated limits — acceptable.
2. **Domain export с plaintext passwords** ([`internal/domain/transfer.go`](../internal/domain/transfer.go)) — by design; **mitigation:** R13 (optional password encryption).
3. **`macro()` dual lookup** ([`internal/milter/milter.go`](../internal/milter/milter.go)) — workaround для Postfix/go-milter; permanent, not a bug.
### Рекомендации
| # | Действие | Приоритет | Модель |
|---|----------|-----------|--------|
| L1 | **Предрелизный security review** (§ D) — обязательный гейт | **P0** | **Fable** |
| L2 | **Шифрование бэкапа и экспорта домена** (R13) — optional, checkbox + password — **выполнено** | P1 | **Opus** + Sonnet |
| L3 | Send-log gap mitigation — **выполнено** (persist offset, Фаза 3) | P2 | Opus |
| L4 | Rate limit count+insert — **выполнено** (учёт «в полёте», Фаза 3; транзакция как таковая неприменима) | P3 | Opus |
---
## 7. Legacy-код и миграции
### SQL-миграции
- [`0001_init.sql`](../internal/store/migrations/0001_init.sql) — initial schema
- [`0002_sessions.sql`](../internal/store/migrations/0002_sessions.sql) — sessions (plan B.1)
- [`0003_logtail_state.sql`](../internal/store/migrations/0003_logtail_state.sql) — log-tailer read offset (Фаза 3)
- Механизм: `PRAGMA user_version`, embedded FS, transactional apply — **чистый**, без legacy branches в коде.
### Архивная документация
- [`docs/archive/specification-v1.0.md`](archive/specification-v1.0.md) — historical; помечен «не источник истины».
- **Рекомендация:** оставить в archive; в коде заменить «spec 7.x» на актуальные doc-ссылки.
### Legacy patterns в runtime
| Элемент | Статус | Действие |
|---------|--------|----------|
| Phase 014 comments | Historical noise | Cleanup (Haiku) |
| `macro()` brace workaround | Permanent Postfix compat | Оставить, уже документировано |
| Legacy charset (windows-1251) in milter | Keep raw header on decode fail | Оставить |
| In-memory sessions | **Удалено** (B.1 → SQLite) | Done |
| `copytruncate` log rotation | **Заменено** (B.2 → rename+reload) | Done |
**Перспектива удаления:** единственный кандидат на cleanup — **phase-комментарии** и **архив spec** (оставить файл, убрать ссылки из кода).
---
## 8. GUI: «костыли» и оптимизация компоновки
### Стек
Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.css) + [`panel.js`](../internal/web/static/panel.js). **Нет React/Vue** — минимальный footprint.
### Поиск маркеров долга
**TODO / FIXME / HACK / kostyl — 0 вхождений** по всему репозиторию.
### Осознанные компромиссы (не костыли)
| Компромисс | Файл | Обоснование |
|------------|------|-------------|
| External CSS/JS only (no inline) | `panel.css`, `panel.js` | CSP `default-src 'self'` |
| HTMX `includeIndicatorStyles: false` | `layout.html` | Avoid CSP exception |
| Block layout for applications (not table) | `panel.css` | 4 cols + 6 controls don't fit 48rem |
| Two-row nav | `panel.css` | Session block vs page links width |
| Page-specific max-width (48/64/24rem) | `panel.css` | Monitoring vs forms |
| Subject ellipsis via inner `<span>` | `panel.css` | `max-width` on `<td>` is advisory |
| Dark mode `!important` overrides | `panel.css` | Override specificity without restructuring |
| HTMX poll excluded from session renewal | `middleware.go` | Idle timeout semantics |
| Dual cookie names | `handlers_auth.go` | `__Host-` requires Secure |
### Возможные оптимизации GUI
| # | Оптимизация | Effort | Модель |
|---|-------------|--------|--------|
| G1 | HTMX polling only when tab visible (`document.visibilityState`) | Low | Sonnet |
| G2 | CSS custom properties для dark mode вместо `!important` cascade | Medium | Sonnet |
| G3 | Consolidate duplicate `main { max-width }` rules | Trivial | Haiku |
| G4 | `hx-trigger="every 5s"` → adaptive interval (5s active, 30s idle) | Low | Sonnet |
**Вердикт:** GUI **не содержит костылей**; все workarounds документированы и оправданы CSP/layout constraints.
---
## 9. Слабо задокументированные спорные решения
### Хорошо задокументированные (security.md + code comments)
- Fail-open journal-milter vs fail-closed OpenDKIM
- CSRF via Origin (no tokens)
- `__Host-` cookie + duplicate detection
- Plaintext passwords in domain export → **mitigation:** R13
- Send-log queued gap
- SQLite single connection
- No in-container TLS
### Требуют усиления документации
| Решение | Текущее состояние | Рекомендация |
|---------|-------------------|--------------|
| **Почему нет CSRF-токенов** (только Origin check) | Частично в security.md | Добавить ADR-style параграф в security.md |
| **Почему panel HTTP, не HTTPS** | README + architecture | Достаточно |
| **Почему chroot disabled в Postfix** | architecture.md | Достаточно |
| **Порядок supervisord** (opendkim → panel → postfix) | architecture.md | Достаточно |
| **Почему log-tailer не persist offset** | roadmap optional | Явно в architecture.md § known limitations |
| **Import domain с plaintext password** | transfer.go comment | Достаточно для v1; шифрование — R13 |
| **Plaintext full backup / domain export at rest** | handlers_backup.go | **R13:** optional AES-GCM envelope (`.spbk`/`.spde`) |
**Модель:** Sonnet (дополнить security.md / architecture.md)
---
## 10. Прочие предложения по оптимизации
### Pre-release (блокеры)
| # | Задача | Модель | Ref |
|---|--------|--------|-----|
| R0 | Security review diff v1.0.0→HEAD + checklist 7.6 — **выполнено** (2026-08-06) | **Fable** | implementation-plan § D |
| R1 | Bump image tag in compose при git tag — **открыто**, делается в релизном коммите вместе с тегом | Sonnet | roadmap § v1.x |
| R2 | ~~Codeberg URLs в README Quick start~~**снято**: Codeberg уходит, GitHub остаётся единственной площадкой. Вместо перевода ссылок *на* Codeberg сделан обратный переезд: URL, лицензионные шапки SVG/HTML и путь Go-модуля | Sonnet | — |
### v1.x polish (не блокеры)
| # | Задача | Модель |
|---|--------|--------|
| R3 | Cleanup phase-комментариев (50+ files) — **выполнено** (Фаза 1) | Haiku |
| R4 | Fix stale comment in handlers_domains.go — **выполнено** (Фаза 1) | Haiku |
| R5 | docs/logo: создать или удалить из roadmap — **выполнено** (Фаза 1) | Haiku |
| R6 | GUI: visibility-aware HTMX polling — **выполнено** (Фаза 2) | Sonnet |
| R7 | CONTRIBUTING.md — **перенесено в 2.x** ([roadmap.md](roadmap.md)): у проекта один разработчик и нет внешнего потока PR, документ был бы без аудитории | Sonnet |
| R8 | ADR для CSRF policy — **выполнено** (Фаза 1, [security.md](security.md)) | Sonnet |
| R13 | Шифрование бэкапа и экспорта домена (checkbox + password) — **выполнено** | **Opus** + Sonnet |
### v2.x (roadmap, не начинать без согласования)
| # | Задача | Модель |
|---|--------|--------|
| R9 | Inbound relay (Phase O1) | **Opus** |
| R10 | Domain-admin role | Opus |
| R11 | Send-log gap fix (persist offset) — **выполнено в Фазе 3**, из 2.x снято | Opus |
| R12 | Split internal/web subpackages | Sonnet/Opus |
### CI/infra
- E2E готов (`test/e2e/`); release workflow matrix amd64/arm64 — **хорошо**.
- `go vet` + `go test` на push — **достаточно** для v1.x.
- Рекомендация: добавить `gofmt -l` check в CI (progress.md упоминает как manual step) — **выполнено** (Фаза 1, [.github/workflows/test.yml](../.github/workflows/test.yml)).
**Модель:** Haiku (CI one-liner)
---
## План реализации (приоритизированный)
### Фаза 0 — Гейт релиза (P0)
Содержательная часть закрыта 2026-08-06; остались только шаги самой резки
версии, которые делаются по явной команде оператора.
1. Fable: `/security-review` по diff v1.0.0...HEAD — **выполнено**
2. Fable: ручной проход security.md checklist § 7.6 — **выполнено**
3. Каждая finding → fix ИЛИ запись в security.md — **выполнено** (одна правка
defence-in-depth, принятые риски не пополнились)
4. `make e2e` зелёный — **выполнено** (dev-сервер)
5. ~~Codeberg URLs~~**снято**, см. R2: переезд сделан в обратную сторону, на
GitHub. Остаётся bump тега образа в compose — **открыто**, в релизном коммите
6. Git tag vX.Y.Z — **открыто**, [roadmap.md](roadmap.md) § v1.x
### Фаза 1.5 — Шифрование резервных копий (P1, v1.x) — **выполнено 2026-08-06**
Реализовано как спланировано: `internal/secretfile` (E1) → `selfpost-backup`
+ панель (E2) → экспорт/импорт домена (E3) → UI-чекбокс (E4) → docs (E5).
Отличия от плана: конверт потоковый (64 KiB чанки AES-256-GCM с AAD
`header+counter+last`), а не одноблочный, иначе полный бэкап пришлось бы
держать в памяти целиком; манифест остался внутри tar, то есть внутри
шифротекста, как и планировалось; в CLI добавлен режим `-decrypt` — без него
зашифрованный бэкап нечем распаковать при restore. Детали —
[progress.md](progress.md), CHANGELOG `[Unreleased]`.
**Проблема:** полный бэкап и экспорт домена содержат DKIM-ключи, SASL-креды и plaintext-пароли приложений; сейчас `.tar.gz` / `.json` без шифрования.
**Решение:** опциональное шифрование паролем (чекбокс «Encrypt with password»; поля password + confirm — только при включённой галочке; переключение в `panel.js`, без inline script).
| Арtefact | Cleartext | Encrypted |
|----------|-----------|-----------|
| Полный бэкап | `.tar.gz` | `.spbk` (**S**elf**P**ost **B**ac**k**up) |
| Экспорт домена | `.json` | `.spde` (**S**elf**P**ost **D**omain **E**xport) |
**Формат:** magic `SELFPOST1`, type byte, scrypt KDF, AES-256-GCM; manifest внутри ciphertext.
**Задачи:** E1 crypto envelope → E2 backup/CLI → E3 domain export/import → E4 UI (checkbox) → E5 docs + e2e. **Модель:** Opus (crypto), Sonnet (UI/docs).
### Фаза 1 — Doc/code hygiene (P1) — **выполнено 2026-08-06**
1. Haiku: массовая замена phase-комментариев (mechanical pass) — **сделано**
2. Haiku: fix handlers_domains.go stale comment — **сделано**
3. Sonnet: ADR CSRF в security.md — **сделано**
4. Sonnet: known limitations § в architecture.md (send-log gap) — **уже было**
в § Log tailer, правка не потребовалась
5. Haiku: docs/logo resolve — **сделано**
6. Haiku: gofmt CI check — **сделано**
Добор той же фазы (2026-08-06, отдельным проходом): ссылки на архивную
спецификацию убраны из кода целиком (§ 4), добавлена диаграмма слоёв в
architecture.md (A2), расширен `TestParseDelivery` (§ 3) — последнее вскрыло
реальный баг разбора `status=`.
### Фаза 2 — GUI polish (P2, optional) — **выполнено 2026-08-06**
1. Sonnet: HTMX visibility-aware polling (panel.js) — **сделано иначе**:
фильтр повешен на `htmx:beforeRequest`, а не на встроенный фильтр триггера
htmx — тот вычисляется через `new Function`, что CSP панели без
`unsafe-eval` молча ломает
2. Sonnet: CSS custom properties для dark mode — **сделано**
3. Haiku: consolidate main max-width rules — **сделано**
### Фаза 3 — Operational improvements (P2P3, optional) — **выполнено 2026-08-06**
1. Opus: send-log read offset persistence — **сделано**: `logtail_state`
(миграция `0003`) хранит offset + отпечаток головы лога; при совпадении
отпечатка чтение продолжается, при несовпадении файл читается с начала,
первый запуск (записи нет) — с конца, как раньше.
2. Opus: rate limit count transaction wrap — **сделано иначе**: буквальная
транзакция невозможна, count живёт на MAIL FROM, insert — на end-of-message,
это разные стадии SMTP-транзакции. Overshoot закрыт учётом сообщений «в
полёте» (`internal/milter/inflight.go`): к счёту из БД добавляются
резервации, взятые прошедшими проверку сессиями и снимаемые после записи в
send-log, на ABORT или по TTL 10 минут.
Остаток по send-log (не закрывается персистом offset): при пересоздании
контейнера `mail.log` теряется вместе с ним — принятый риск в
[security.md](security.md).
---
## Маршрутизация моделей (сводная таблица)
| Тип работы | Модель | Обоснование |
|------------|--------|-------------|
| Security review (не authorship) | **Fable** | Независимость от автора (Opus) |
| Security fixes, infra, Postfix | **Opus** | Risk-critical |
| UI, docs, CSS, templates | **Sonnet** | Баланс качества и скорости |
| Mechanical cleanup, CI, trivial fixes | **Haiku** | Минимальный scope |
| Inbound relay 2.x | **Opus** | Open relay risk |
*Источник правил:* [progress.md](progress.md) § «Модель по типу работы», [development.md](development.md) § Agent rules.
+2 -3
View File
@@ -4,9 +4,8 @@
безопасности (§ D) выполнена 2026-08-06, релизный гейт (e2e + ревизия) открыт. безопасности (§ D) выполнена 2026-08-06, релизный гейт (e2e + ревизия) открыт.
B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md), B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md),
e2e/CI в [development.md](development.md), принятые риски в e2e/CI в [development.md](development.md), принятые риски в
[security.md](security.md). Комплексное рецензирование кодовой базы и план [security.md](security.md). Текущее состояние и следующий шаг:
доработок — [code-review.md](code-review.md). Текущее состояние и следующий [progress.md](progress.md). Объём 2.x.x — [roadmap.md](roadmap.md).
шаг: [progress.md](progress.md). Объём 2.x.x — [roadmap.md](roadmap.md).
**Основа:** [product.md](product.md) v1.0. **Основа:** [product.md](product.md) v1.0.
+7 -8
View File
@@ -5,7 +5,6 @@
Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md). Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md).
Продукт: [product.md](product.md), устройство: [architecture.md](architecture.md). Продукт: [product.md](product.md), устройство: [architecture.md](architecture.md).
Процесс разработки: [development.md](development.md). Принятые риски безопасности: [security.md](security.md). Процесс разработки: [development.md](development.md). Принятые риски безопасности: [security.md](security.md).
Рецензирование кодовой базы и план доработок: [code-review.md](code-review.md).
История релизов: [CHANGELOG.md](../CHANGELOG.md). История релизов: [CHANGELOG.md](../CHANGELOG.md).
История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется.
@@ -48,14 +47,14 @@
- **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 ./...` чистые. - **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` пути. - **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 — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя»; из него остался только бамп тега образа (Quick start и `docs/logo` закрыты). - **Документация:** план D1–D9 закрыт ([documentation-plan.md](documentation-plan.md) — только метод и правила поддержки). Хвост v1.x — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя»; из него остался только бамп тега образа (Quick start и `docs/logo` закрыты).
- **Рецензирование кодовой базы** (2026-08-05): [code-review.md](code-review.md) — 10 разделов (архитектура, качество, docs, GUI, legacy, риски), приоритизированный план реализации и маршрутизация моделей. Критичных багов не найдено; блокер релиза — § D ниже. - **Рецензирование кодовой базы** (2026-08-05, `522425a`): 10 разделов (архитектура, качество, docs, GUI, legacy, риски) плюс приоритизированный план доработок фазами 0–3. Критичных багов не найдено; единственным блокером релиза названа § D. **План выполнен целиком** (см. записи ниже), поэтому сам документ `docs/code-review.md` удалён — незакрытые пункты унесены в [roadmap.md](roadmap.md) (разбиение `internal/web`, индекс документации в README, адаптивный интервал опроса, `CONTRIBUTING.md`), остальное либо сделано, либо уже описано в architecture.md / security.md / комментариях кода. Текст ревизии — в git-истории.
- **§ D выполнен (2026-08-06):** предрелизная ревизия безопасности моделью Fable — диф от аудита v1.0 (Фаза 11, `bd64e80`) до HEAD + полный проход по чек-листу [security.md](security.md) (бывшее ТЗ 7.6). Эксплуатируемых находок нет; одна правка defence-in-depth (`--` перед логином в argv `saslpasswd2`, `internal/app/sasl.go` + тест). Принятые риски не пополнились. Детали — [implementation-plan.md](implementation-plan.md) § D и CHANGELOG `[Unreleased]/Security`. Локально `go vet`/`go test ./internal/app/...` чистые; падения `internal/domain` (`TestWriteLoadPrivateKeyRoundtrip`, `TestRenderTables`) и `internal/logtail` (`TestFollowTailsAndRotates`) — Windows-специфика (права файлов/`\` в путях/rename открытого файла), на Linux CI зелено. - **§ 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 плана ревизии выполнена (2026-08-06)** (doc/code hygiene, P1): cleanup ~30 stale «Phase N» комментариев в коде и shell-скриптах; исправлен stale-комментарий в `handlers_domains.go`; ADR CSRF (Origin vs токены) добавлен в [security.md](security.md); known-limitations по log-tailer уже был в [architecture.md](architecture.md) § Log tailer — отдельного действия не потребовалось; `docs/logo` в [roadmap.md](roadmap.md) закрыт (каталога нет, критерию соответствует); `gofmt -l` добавлен в CI (`.github/workflows/test.yml`). `gofmt`/`go vet`/`go test ./...` чистые в обоих модулях (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-сервере. - **Фаза 1.5 плана ревизии выполнена (2026-08-06)** (шифрование резервных копий, P1): новый пакет `internal/secretfile` — конверт `magic SELFPOST1 | type | scrypt-параметры | salt | nonce-prefix` + поток 64 KiB чанков AES-256-GCM, каждый с AAD `header+counter+last`, поэтому обрезка, перестановка и подмена не открываются (стриминг в обе стороны — полный бэкап не держится в памяти). Панель: чекбокс «Encrypt with a password» в форме полного бэкапа и экспорта домена (общий партиал `templates/encrypt_fields.html`, показ/очистка полей — `panel.js`, без inline-скриптов), импорт домена принимает `.spde` (шифрование определяется по magic, не по расширению) с полем пароля. CLI `selfpost-backup`: пишет `.spbk` при заданном пароле и умеет `-decrypt` (иначе зашифрованный бэкап нечем распаковать при restore); пароль — только `SELFPOST_BACKUP_PASSWORD` / `-password-file`, никогда argv. Умолчание не изменилось: галочка снята — прежние `.tar.gz` / `.json` байт в байт. Тесты: round-trip по размерам (0, границы чанка, несколько чанков), неверный пароль, обрезка, перестановка чанков, порча байта, чужие KDF-параметры; валидация формы пароля; round-trip CLI create→decrypt→tar. Docs: README § *Encrypting a backup or export*, [security.md](security.md) § «Резервная копия и экспорт домена» + принятый риск (шифрование опционально), [architecture.md](architecture.md) § Persistence. `gofmt`/`go vet`/`go test ./...` чистые (кроме известных Windows-падений `internal/domain`, `internal/logtail`). E2E-сценарий не добавлялся: в `test/e2e/` бэкапа не было и раньше, а прогнать новый тест локально нечем (нет Docker) — кандидат при следующем прогоне на dev-сервере.
- **Фаза 2 выполнена (2026-08-06)** ([code-review.md](code-review.md) § Фаза 2 — GUI polish, P2): опрос мониторинговых страниц не уходит на сервер, пока вкладка скрыта — фильтр повешен на `htmx:beforeRequest` в `panel.js`, а не на встроенный в htmx фильтр триггера (тот вычисляется через `new Function`, что CSP панели `default-src 'self'` без `unsafe-eval` молча ломает); тёмная тема переписана с каскада `!important` на переопределение CSS-переменных в одном блоке `prefers-color-scheme: dark`; дублирующее правило `main { max-width }` сведено к одному базовому плюс задокументированные постраничные оверрайды. Только CSS/JS, поведения сервера не касается; вживую не проверялось (нет Docker локально) — кандидат на следующий прогон на dev-сервере. - **Фаза 2 плана ревизии выполнена (2026-08-06)** (GUI polish, P2): опрос мониторинговых страниц не уходит на сервер, пока вкладка скрыта — фильтр повешен на `htmx:beforeRequest` в `panel.js`, а не на встроенный в htmx фильтр триггера (тот вычисляется через `new Function`, что CSP панели `default-src 'self'` без `unsafe-eval` молча ломает); тёмная тема переписана с каскада `!important` на переопределение CSS-переменных в одном блоке `prefers-color-scheme: dark`; дублирующее правило `main { max-width }` сведено к одному базовому плюс задокументированные постраничные оверрайды. Только CSS/JS, поведения сервера не касается; вживую не проверялось (нет Docker локально) — кандидат на следующий прогон на dev-сервере.
- **Фаза 3 выполнена (2026-08-06)** ([code-review.md](code-review.md) § Фаза 3 — operational improvements, P2P3): (1) log-tailer сохраняет позицию чтения — таблица `logtail_state` (миграция `0003`, `internal/store/logtail.go`) хранит offset + отпечаток первых 512 байт лога, `internal/logtail/offset.go` решает откуда стартовать: отпечаток совпал → продолжаем с offset (дочитывается хвост, написанный пока панель лежала); не совпал (лог сменился/пересоздан) → читаем файл с начала (повторный разбор безвреден, `UpdateStatus` идемпотентен); записи нет вовсе (первый запуск) → с конца, как раньше. Запись offset — не чаще раза в 5 с, плюс форс при ротации и на выключении; сохраняется позиция *потреблённых* байт (минус недочитанная частичная строка). (2) L2-лимит перестал промахиваться при параллельных сессиях: между проверкой на MAIL FROM и вставкой строки на end-of-message сообщение не видно в БД, поэтому N одновременных сессий пропускали друг друга — теперь к счёту из БД добавляются «в полёте» (`internal/milter/inflight.go`, общий на процесс реестр резерваций); резервация освобождается после записи в send-log, на ABORT и по TTL 10 минут (у go-milter нет колбэка на закрытие соединения, а вечная резервация — это fail-closed-дрейф, которого у лимитера быть не должно). Транзакция «count+insert», как предлагал review, невозможна буквально: эти два шага разнесены по разным стадиям SMTP-транзакции. Тесты: restart/rotation-resume для tailer'а, четыре сценария резерваций для лимита. `gofmt`/`go vet` чистые; `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). Не проверено на стенде (нет Docker локально) — кандидат на следующий прогон на dev-сервере. - **Фаза 3 плана ревизии выполнена (2026-08-06)** (operational improvements, P2P3): (1) log-tailer сохраняет позицию чтения — таблица `logtail_state` (миграция `0003`, `internal/store/logtail.go`) хранит offset + отпечаток первых 512 байт лога, `internal/logtail/offset.go` решает откуда стартовать: отпечаток совпал → продолжаем с offset (дочитывается хвост, написанный пока панель лежала); не совпал (лог сменился/пересоздан) → читаем файл с начала (повторный разбор безвреден, `UpdateStatus` идемпотентен); записи нет вовсе (первый запуск) → с конца, как раньше. Запись offset — не чаще раза в 5 с, плюс форс при ротации и на выключении; сохраняется позиция *потреблённых* байт (минус недочитанная частичная строка). (2) L2-лимит перестал промахиваться при параллельных сессиях: между проверкой на MAIL FROM и вставкой строки на end-of-message сообщение не видно в БД, поэтому N одновременных сессий пропускали друг друга — теперь к счёту из БД добавляются «в полёте» (`internal/milter/inflight.go`, общий на процесс реестр резерваций); резервация освобождается после записи в send-log, на ABORT и по TTL 10 минут (у go-milter нет колбэка на закрытие соединения, а вечная резервация — это fail-closed-дрейф, которого у лимитера быть не должно). Транзакция «count+insert», как предлагал review, невозможна буквально: эти два шага разнесены по разным стадиям SMTP-транзакции. Тесты: restart/rotation-resume для tailer'а, четыре сценария резерваций для лимита. `gofmt`/`go vet` чистые; `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). Не проверено на стенде (нет Docker локально) — кандидат на следующий прогон на dev-сервере.
- **Добор по code-review выполнен (2026-08-06):** (1) проект переехал на единственную площадку — GitHub (Codeberg уходит): вместе с URL, лицензионными шапками SVG/HTML и docs переехал путь Go-модуля на `github.com/mixeme/selfpost` (`go.mod`, `test/e2e/go.mod`, все импорты, `MODULE` в Makefile, `-ldflags` в Dockerfile и development.md) — оставлять импорты на исчезающем хосте нельзя, `go get`/`go install` сломались бы; (2) ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x» из § 4 ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) [architecture.md](architecture.md) § Code layers — диаграмма слоёв (A2); (4) `TestParseDelivery` расширен экзотикой mail.log — и **вскрыл реальный баг**: шаблон брал `status=` жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с `status=sent` в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) R7 (`CONTRIBUTING.md`) перенесён в 2.x, R1 и git-тег оставлены в [roadmap.md](roadmap.md) § v1.x. `gofmt`/`go vet` чистые в обоих модулях, `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). На стенде не проверялось (нет Docker локально). - **Добор по плану ревизии выполнен (2026-08-06):** (1) проект переехал на единственную площадку — GitHub (Codeberg уходит): вместе с URL, лицензионными шапками SVG/HTML и docs переехал путь Go-модуля на `github.com/mixeme/selfpost` (`go.mod`, `test/e2e/go.mod`, все импорты, `MODULE` в Makefile, `-ldflags` в Dockerfile и development.md) — оставлять импорты на исчезающем хосте нельзя, `go get`/`go install` сломались бы; (2) ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», как просило ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) [architecture.md](architecture.md) § Code layers — диаграмма слоёв (A2); (4) `TestParseDelivery` расширен экзотикой mail.log — и **вскрыл реальный баг**: шаблон брал `status=` жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с `status=sent` в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) `CONTRIBUTING.md` перенесён в 2.x, бамп тега образа и git-тег оставлены в [roadmap.md](roadmap.md) § v1.x. `gofmt`/`go vet` чистые в обоих модулях, `go test ./...` — падения только известные Windows-специфичные (`internal/domain`, `TestFollowTailsAndRotates`). На стенде не проверялось (нет Docker локально).
- **Дальше:** релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа в compose, git tag) по явной команде пользователя. Все polish-фазы из [code-review.md](code-review.md) (1, 1.5, 2, 3) закрыты. - **Дальше:** релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа в compose, git tag) по явной команде пользователя. Все polish-фазы плана ревизии (1, 1.5, 2, 3) закрыты.
- **Принятые риски** — [security.md](security.md). **Опционально v1.x / 2.x** — [roadmap.md](roadmap.md) (хвост документации, send-log gaps, Фаза O1+, роль администратора домена). - **Принятые риски** — [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/` это делает). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
+52 -15
View File
@@ -25,9 +25,9 @@
[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить [deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить
до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше. до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше.
Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает
только до первого выката по тегу. Сам тег — последний шаг Фазы 0 только до первого выката по тегу. Сам тег — последний шаг релизного гейта:
[code-review.md](code-review.md): содержательная часть гейта (e2e C.4, ревизия содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде
§ D) закрыта, режется по явной команде оператора. После тега `release.yml` оператора ([progress.md](progress.md)). После тега `release.yml`
собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`, поэтому compose с новым собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`, поэтому compose с новым
тегом и сам тег обязаны появиться вместе — иначе compose неделю ссылается на тегом и сам тег обязаны появиться вместе — иначе compose неделю ссылается на
несуществующий образ. несуществующий образ.
@@ -48,9 +48,9 @@ CHANGELOG `[Unreleased]/Security`, а разделы B.1–B.3 и C.4 вырез
[development.md](development.md). [development.md](development.md).
3. Перецелить ссылки из документации: [README.md](../README.md) («Open v1.x 3. Перецелить ссылки из документации: [README.md](../README.md) («Open v1.x
questions» — открытых вопросов там нет) → [progress.md](progress.md); questions» — открытых вопросов там нет) → [progress.md](progress.md);
[code-review.md](code-review.md), [security.md](security.md), [security.md](security.md), [documentation-plan.md](documentation-plan.md),
[documentation-plan.md](documentation-plan.md), [progress.md](progress.md) и [progress.md](progress.md) и шапку этого файла → на
шапку этого файла → на `progress.md`/`security.md`. `progress.md`/`security.md`.
4. В [progress.md](progress.md) убрать шаг «Открыть `implementation-plan.md`» — 4. В [progress.md](progress.md) убрать шаг «Открыть `implementation-plan.md`» —
он выполнен. он выполнен.
@@ -64,8 +64,23 @@ git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок
`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал `raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал
больше нет.) больше нет.)
**Сводный индекс документации в README.** Ссылки на `docs/` разбросаны по
тексту README (блок в шапке плюс упоминания по месту), единого списка нет —
читателю, который ищет «а где вообще что», приходится вычитывать документ.
Стоит одного абзаца со списком всех файлов `docs/` и одной строкой на каждый.
Мелочь, но именно она делает набор документов набором, а не россыпью.
**Опрос мониторинга у открытой, но незанятой вкладки.** Скрытая вкладка уже не
опрашивает сервер (фильтр на `htmx:beforeRequest` в
[panel.js](../internal/web/static/panel.js)). Остаток: вкладка на переднем
плане, с которой не работают, всё равно ходит раз в 5 с. Кандидат — адаптивный
интервал (5 с при активности, 30 с при простое) по `htmx:afterRequest` без
изменения `hx-trigger`. Ценность низкая: нагрузка — один SQL-запрос и рендер
фрагмента, так что это скорее гигиена, чем экономия. Допустимый исход —
осознанно не делать.
**Send-log vs `mail.log` (частично закрыто).** Persist позиции чтения сделан **Send-log vs `mail.log` (частично закрыто).** Persist позиции чтения сделан
(Фаза 3 [code-review.md](code-review.md), таблица `logtail_state`): после (таблица `logtail_state`, миграция `0003`): после
рестарта панели log-tailer дочитывает пропущенный хвост. Остаётся пересоздание рестарта панели log-tailer дочитывает пропущенный хвост. Остаётся пересоздание
контейнера — `mail.log` не в `/data` и теряется вместе с ним, такие строки контейнера — `mail.log` не в `/data` и теряется вместе с ним, такие строки
навсегда останутся `queued`. Кандидаты, если станет больно: volume для лога, навсегда останутся `queued`. Кандидаты, если станет больно: volume для лога,
@@ -139,15 +154,37 @@ Windows → сборка и прогон на Debian-сервере, потом
[development.md](development.md) и [progress.md](progress.md) — то есть на [development.md](development.md) и [progress.md](progress.md) — то есть на
русском и вперемешку с внутренним состоянием проекта. русском и вперемешку с внутренним состоянием проекта.
**Почему 2.x, а не v1.x** (перенесено из [code-review.md](code-review.md) § 10, **Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
пункт R7, где стояло как «опционально v1.x»). Файл имеет смысл, когда есть проекта один разработчик и внешнего потока PR нет, поэтому сейчас
кому его читать: у проекта один разработчик и внешнего потока PR нет, поэтому `CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
сейчас `CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, расходится правда о dev loop. Уместен вместе с тем, что реально открывает
где расходится правда о dev loop. Уместен вместе с тем, что реально открывает проект вовне: английская документация процесса (сейчас процессные документы —
проект вовне: английская документация процесса (сейчас RU/EN split — барьер `progress.md`, `roadmap.md`, `development.md` — на русском, а README и
для EN-only контрибьюторов, [code-review.md](code-review.md) § 4) и первый `architecture.md` на английском; для EN-only контрибьютора это барьер) и
внешний интерес после публикации релиза. первый внешний интерес после публикации релиза.
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к **Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
проверкам перед PR и протокол коммитов; [development.md](development.md) не проверкам перед PR и протокол коммитов; [development.md](development.md) не
дублирует его, а ссылается. дублирует его, а ссылается.
---
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
**Что это.** `internal/web` — самый крупный пакет проекта: 47 файлов, ~3030
строк, в одной плоскости лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
2.x выше добавляют в него код — роль администратора домена приносит
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
разрезан, либо зафиксировано, что он остаётся плоским.