From 284bd22dfb182e8f895e7b658496ae26db76e5c8 Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 9 Aug 2026 00:46:03 +0300 Subject: [PATCH] docs: align operator and as-built docs with the code Fix setup URL shape, import encryption UI, architecture layering/routes, and stale plan/roadmap pointers so the prose matches what the tree does. Co-Authored-By: Composer Co-authored-by: Cursor --- CHANGELOG.md | 11 ++++++++ README.md | 5 +++- docs/architecture.md | 22 ++++++++++------ docs/development.md | 12 ++++++--- docs/guide.md | 51 ++++++++++++++++++++++++------------- docs/implementation-plan.md | 5 ++-- docs/roadmap.md | 9 ++++--- 7 files changed, 78 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 059158c..37e31ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- Docs aligned with the code: setup URL is `/setup/` (README and + guide; local trial rewrites the printed `https:///…` link to + `http://127.0.0.1:8080/…`); domain import uses the file extension / magic + bytes for the password field, not an "encrypted" checkbox; architecture + layering and route table match `web`→`store` and `POST /domains/import`; + OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived + "spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes, + and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts vs + the still-pinned compose `0.1.0` image are called out in the guide and + `development.md`. Roadmap / implementation-plan point at CHANGELOG + `[0.5.0]` Security and refreshed `internal/web` size / symbol links. - Full backups no longer carry `/data/log`. It is Postfix's raw log plus its fourteen rotated copies — diagnostic output rather than state to restore, and otherwise by far the largest thing in the archive. diff --git a/README.md b/README.md index 129f68a..b7e1639 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,10 @@ docker logs selfpost-try 2>&1 | grep -m1 'http' docker exec selfpost-try cat /data/setup-token ``` -Open the printed `http://…/setup?token=…` link before it expires. +The printed URL is `https://mail.local.test/setup/`. For this local +trial rewrite it to `http://127.0.0.1:8080/setup/` (same path token; +`PANEL_COOKIE_SECURE=false` so the cookie works over plain HTTP). Open it +before it expires. When finished: diff --git a/docs/architecture.md b/docs/architecture.md index 9726322..353b039 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)): | Program | User | Priority | Role | |---|---|---|---| -| `opendkim` | opendkim | 100 | DKIM signing milter | +| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | | `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine | | `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | @@ -151,8 +151,10 @@ state for an older message and the page reports it as such, not as a failure. ## Panel HTTP surface -Route table: [internal/web/web.go](../internal/web/web.go). Authenticated -unless noted. +Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated +unless noted. The table below is a summary — HTMX fragment endpoints +(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`, +`/system-log/body`, …) and every POST variant live in `web.go`. | Route | Purpose | |---|---| @@ -161,12 +163,13 @@ unless noted. | `/login`, `/logout` | Session auth | | `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | +| `/domains/import` | Domain import (`POST`; form on the Backup page) | | `/deliveries` | Send log with filters | | `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/mail-queue` | Postfix queue view | | `/system-log` | `mail.log` tail | | `/reload` | Reload OpenDKIM + Postfix maps | -| `/backup` | Full backup download, domain import | +| `/backup` | Full backup download (page also hosts the import form) | | `/account` | Admin username/password | HTMX polling refreshes monitoring fragments (5 s while the operator is active on @@ -198,10 +201,12 @@ cookie and idle timeout has not expired. ## Code layers -Handlers never touch SQLite or the filesystem directly; every write that has to -land in more than one place (SQLite row, `sasldb2` entry, Postfix map, OpenDKIM -table) goes through a service, which is also where the rollback of a partial -failure lives. The adapters below the services are the only code that knows +Multi-store writes that must land in more than one place (SQLite row, +`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is +also where the rollback of a partial failure lives. Handlers may call +`store` directly for single-table reads and simple writes (sessions, admin, +send-log queries); the first-run setup-token file is read and written in +`web` itself. The adapters below the services are the only code that knows about Postfix, OpenDKIM, DNS or the log file, which is what makes them substitutable in tests — `milter.Store`, `app.SenderMaps` and `logtail.StatusStore` are the seams the unit tests replace with fakes. @@ -236,6 +241,7 @@ flowchart TB panel --> logtail backupcli --> backupPkg backupcli --> secretfile + web --> store web --> domainSvc web --> appSvc web --> backupPkg diff --git a/docs/development.md b/docs/development.md index a30641a..42b2b83 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,7 +44,7 @@ mechanics → Haiku. Reviewers must not be the author of the code under review. |---|---| | **Go** | 1.26+ (`go.mod`); `CGO_ENABLED=0` — pure Go, static linking | | **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) | -| **Build** | [Makefile](../Makefile): `vet`, `test`, `build`, `e2e` | +| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) | | **Container** | Docker + Compose v2 on the dev host and in CI | | **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) | | **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate | @@ -163,7 +163,11 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to commit as the tag (see [roadmap.md](roadmap.md) § «v1.x — documentation and deploy tail»). -Ordinary commits **do not** publish an image. +Ordinary commits **do not** publish an image. Intermediate CHANGELOG version +cuts (`0.2.0`…`0.6.0`) document history on `main`; the compose pin and the only +git release tag may lag until an explicit image publish (today: compose +`0.1.0`, tag `v0.0.1` — see [roadmap.md](roadmap.md) § «v1.x — documentation +and deploy tail»). --- @@ -200,8 +204,8 @@ go test ./... ### Env documentation regression -`go test ./cmd/panel -run TestLoadConfig` — every new `loadConfig` key must -appear in the env lists in [guide.md](guide.md) +`go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'` +— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md) ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)). ### End-to-end (container suite) diff --git a/docs/guide.md b/docs/guide.md index 221675a..1228062 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -59,6 +59,12 @@ reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status page will report missing TLS material until you mount PEM files at `/etc/postfix/tls/fullchain.pem` and `privkey.pem`. +The one-time setup link is always printed as +`https:///setup/` (and written the same way to +`/data/setup-token`). For a local trial that means rewriting the host and +scheme to `http://127.0.0.1:8080/setup/` — the path token is what +matters; the hostname in the printed URL is not reachable as written. + **What works:** the full panel — setup, domains, applications, deliveries view, mail queue, system log. **What does not:** reliable outbound delivery to the public internet (no PTR, no real DNS for your domains, port 25 may be blocked @@ -116,15 +122,17 @@ supported configuration: `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH` (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` - (`/data/postfix/sender_login_maps`). + (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel + always writes `/sender_login_maps`, so overriding this env alone + desyncs the map Postfix reads from the file the panel maintains). - **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_WAIT_TIMEOUT` (`30` seconds). - **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily `postfix reload` to pick up renewed certificates), `LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six - hours; rotated logs are kept 14 days and each rotation triggers - `postfix reload`). + hours; logrotate keeps 14 rotated files on a daily schedule, and each + rotation triggers `postfix reload`). ## DNS setup @@ -212,8 +220,9 @@ service healthy and will mail be accepted?" hours. It lives in the data volume, so it survives a container recreate along with the rest of the state — `./data/log/` on the host — but it is *not* included in backups: it is diagnostics, not state. -- **Backup** (`/backup`) — download a full-server backup or import a - single-domain export. See [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain). +- **Backup** (`/backup`) — download a full-server backup; the same page hosts + the domain-import form (`POST /domains/import`). See + [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain). - **Account** (`/account`) — change the administrator username and/or password. Application SASL logins are separate and are not changed here. @@ -243,9 +252,10 @@ or poll `docker inspect` health state on the host. **First-time setup link.** On first start the one-time setup URL is printed in the container log (`docker compose logs -f`) and written to `/data/setup-token` inside the container — `./data/setup-token` on the host, mode `0600` — then -deleted when setup completes. The link is a bearer token valid for ten minutes. -If this host ships container logs to a central aggregator, prefer reading the -file: +deleted when setup completes. The link is +`https:///setup/` (path token, not a query string), +valid for ten minutes. If this host ships container logs to a central +aggregator, prefer reading the file: ```sh docker compose exec selfpost cat /data/setup-token @@ -273,10 +283,11 @@ exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as ## Backup, restore, and moving a single domain -Two related but distinct operations — spec 7.5: +Two related but distinct operations +([architecture.md](architecture.md) § Persistence): -- **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all - applications' SASL credentials, `manifest.json` with the version that +- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, + all applications' SASL credentials, `manifest.json` with the version that created it): panel button (*Backup* → *Full backup*), or from the host: ```sh @@ -329,8 +340,10 @@ with AES-256-GCM, in chunks, so a truncated or altered file fails to open rather than restoring quietly. **SelfPost does not store the password** — lose it and the file is unrecoverable, which is the entire point. -*Import a domain* takes an encrypted export directly: tick **The file is -encrypted** and give the password. +*Import a domain* takes an encrypted export directly: choose a `.spde` file and +the password field appears (driven by the file extension in the browser; the +server also detects the envelope by its magic bytes). A plain `.json` export +needs no password. A full backup has to be turned back into a plain archive before it can be unpacked into `/data`, which the CLI does with the same password: @@ -359,10 +372,12 @@ but it can look like an open port in external scans. ## Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. This is a direct consequence of the backup -version check above: the panel binary's embedded version and the image tag -that produced it are the same value by construction (the release CI stamps -both from one git tag — see `.github/workflows/release.yml`), so pinning the -tag is what makes "restore into the same version" a checkable fact rather than +deliberately never `:latest`. Until the `v1.0.0` cut the shipped pin is still +`0.1.0` — intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development +cuts and do not imply a published image of that tag. Pinning matters because of +the backup version check above: the panel binary's embedded version and the +image tag that produced it are the same value by construction (the release CI +stamps both from one git tag — see `.github/workflows/release.yml`), so the +pin is what makes "restore into the same version" a checkable fact rather than a guess. Upgrade by bumping the tag deliberately, not by riding a moving target. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index c94646e..6a58b58 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -27,5 +27,6 @@ e2e/CI в [development.md](development.md), принятые риски в whitelist'ом), не могло быть разобрано getopt как флаг ([internal/app/sasl.go](../internal/app/sasl.go)). Принятые риски в [security.md](security.md) не пополнились — существующие записи (origin-check -fallback, отсутствие CSRF-токенов, send-log gap) покрывают всё найденное. -Сводка ревизии — в записи `Security` CHANGELOG `[Unreleased]`. +fallback, отсутствие CSRF-токенов, ложно-отрицательный `bounced` при сверке +с `postqueue`) покрывают всё найденное. +Сводка ревизии — в записи `Security` CHANGELOG `[0.5.0]`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 448404a..746debd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,7 +38,7 @@ compose неделю ссылается на несуществующий обр **Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт: уникального содержания в нём нет, § D (предрелизная ревизия безопасности) -продублирован в [security.md](security.md) и CHANGELOG `[Unreleased]/Security`, +продублирован в [security.md](security.md) и CHANGELOG `[0.5.0]/Security`, а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только потому, что описывает релизный гейт, пока тот формально не закрыт. При резке версии: @@ -137,7 +137,7 @@ git-тег `vX.Y.Z`; `implementation-plan.md` и `v1.x-closure-plan.md` удал ## Роль администратора домена — кандидат на 2.x -**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go:182](../internal/web/web.go:182)), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([handlers_monitor.go:49](../internal/web/handlers_monitor.go:49)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны. +**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны. **Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей панели, роли» к out of scope (один администратор), поэтому появление второго @@ -172,8 +172,9 @@ README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний т ## Разбиение `internal/web` на подпакеты — кандидат на 2.x -**Что это.** `internal/web` — самый крупный пакет проекта: 47 файлов, ~3030 -строк, в одной плоскости лежат хендлеры всех разделов панели, сессии, +**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов +(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости +лежат хендлеры всех разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и рендер шаблонов. Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам панели.