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 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mixeme
2026-08-09 00:46:03 +03:00
parent 7c79c085e7
commit 1dd0aa9fea
7 changed files with 78 additions and 37 deletions
+11
View File
@@ -51,6 +51,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
guide; local trial rewrites the printed `https://<hostname>/…` 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 - 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 fourteen rotated copies — diagnostic output rather than state to restore, and
otherwise by far the largest thing in the archive. otherwise by far the largest thing in the archive.
+4 -1
View File
@@ -105,7 +105,10 @@ docker logs selfpost-try 2>&1 | grep -m1 'http'
docker exec selfpost-try cat /data/setup-token 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/<token>`. For this local
trial rewrite it to `http://127.0.0.1:8080/setup/<token>` (same path token;
`PANEL_COOKIE_SECURE=false` so the cookie works over plain HTTP). Open it
before it expires.
When finished: When finished:
+14 -8
View File
@@ -26,7 +26,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role | | 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 | | `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | | `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | | `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 ## Panel HTTP surface
Route table: [internal/web/web.go](../internal/web/web.go). Authenticated Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. 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 | | Route | Purpose |
|---|---| |---|---|
@@ -161,12 +163,13 @@ unless noted.
| `/login`, `/logout` | Session auth | | `/login`, `/logout` | Session auth |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/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` | Send log with filters |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines |
| `/mail-queue` | Postfix queue view | | `/mail-queue` | Postfix queue view |
| `/system-log` | `mail.log` tail | | `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/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 | | `/account` | Admin username/password |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on 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 ## Code layers
Handlers never touch SQLite or the filesystem directly; every write that has to Multi-store writes that must land in more than one place (SQLite row,
land in more than one place (SQLite row, `sasldb2` entry, Postfix map, OpenDKIM `sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
table) goes through a service, which is also where the rollback of a partial also where the rollback of a partial failure lives. Handlers may call
failure lives. The adapters below the services are the only code that knows `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 about Postfix, OpenDKIM, DNS or the log file, which is what makes them
substitutable in tests — `milter.Store`, `app.SenderMaps` and substitutable in tests — `milter.Store`, `app.SenderMaps` and
`logtail.StatusStore` are the seams the unit tests replace with fakes. `logtail.StatusStore` are the seams the unit tests replace with fakes.
@@ -236,6 +241,7 @@ flowchart TB
panel --> logtail panel --> logtail
backupcli --> backupPkg backupcli --> backupPkg
backupcli --> secretfile backupcli --> secretfile
web --> store
web --> domainSvc web --> domainSvc
web --> appSvc web --> appSvc
web --> backupPkg web --> backupPkg
+8 -4
View File
@@ -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 | | **Go** | 1.26+ (`go.mod`); `CGO_ENABLED=0` — pure Go, static linking |
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) | | **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 | | **Container** | Docker + Compose v2 on the dev host and in CI |
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) | | **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) |
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate | | **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 commit as the tag (see [roadmap.md](roadmap.md) § «v1.x — documentation and
deploy tail»). 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 ### Env documentation regression
`go test ./cmd/panel -run TestLoadConfig` — every new `loadConfig` key must `go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
appear in the env lists in [guide.md](guide.md) — 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)). ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
### End-to-end (container suite) ### End-to-end (container suite)
+33 -18
View File
@@ -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 page will report missing TLS material until you mount PEM files at
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`. `/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
The one-time setup link is always printed as
`https://<SELFPOST_HOSTNAME>/setup/<token>` (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/<token>` — 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, **What works:** the full panel — setup, domains, applications, deliveries view,
mail queue, system log. **What does not:** reliable outbound delivery to the 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 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` `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` `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 `<POSTFIX_DIR>/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 and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds). `MILTER_WAIT_TIMEOUT` (`30` seconds).
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily - **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
`postfix reload` to pick up renewed certificates), `postfix reload` to pick up renewed certificates),
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six `LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
hours; rotated logs are kept 14 days and each rotation triggers hours; logrotate keeps 14 rotated files on a daily schedule, and each
`postfix reload`). rotation triggers `postfix reload`).
## DNS setup ## 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 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* with the rest of the state — `./data/log/` on the host — but it is *not*
included in backups: it is diagnostics, not state. included in backups: it is diagnostics, not state.
- **Backup** (`/backup`) — download a full-server backup or import a - **Backup** (`/backup`) — download a full-server backup; the same page hosts
single-domain export. See [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain). 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. - **Account** (`/account`) — change the administrator username and/or password.
Application SASL logins are separate and are not changed here. 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 **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` 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 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. deleted when setup completes. The link is
If this host ships container logs to a central aggregator, prefer reading the `https://<SELFPOST_HOSTNAME>/setup/<token>` (path token, not a query string),
file: valid for ten minutes. If this host ships container logs to a central
aggregator, prefer reading the file:
```sh ```sh
docker compose exec selfpost cat /data/setup-token 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 ## 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 - **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
applications' SASL credentials, `manifest.json` with the version that all applications' SASL credentials, `manifest.json` with the version that
created it): panel button (*Backup* → *Full backup*), or from the created it): panel button (*Backup* → *Full backup*), or from the
host: host:
```sh ```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 than restoring quietly. **SelfPost does not store the password** — lose it and
the file is unrecoverable, which is the entire point. the file is unrecoverable, which is the entire point.
*Import a domain* takes an encrypted export directly: tick **The file is *Import a domain* takes an encrypted export directly: choose a `.spde` file and
encrypted** and give the password. 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 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: 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 ## Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `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 deliberately never `:latest`. Until the `v1.0.0` cut the shipped pin is still
version check above: the panel binary's embedded version and the image tag `0.1.0` — intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development
that produced it are the same value by construction (the release CI stamps cuts and do not imply a published image of that tag. Pinning matters because of
both from one git tag — see `.github/workflows/release.yml`), so pinning the the backup version check above: the panel binary's embedded version and the
tag is what makes "restore into the same version" a checkable fact rather than 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 a guess. Upgrade by bumping the tag deliberately, not by riding a moving
target. target.
+3 -2
View File
@@ -27,5 +27,6 @@ e2e/CI в [development.md](development.md), принятые риски в
whitelist'ом), не могло быть разобрано getopt как флаг whitelist'ом), не могло быть разобрано getopt как флаг
([internal/app/sasl.go](../internal/app/sasl.go)). Принятые риски в ([internal/app/sasl.go](../internal/app/sasl.go)). Принятые риски в
[security.md](security.md) не пополнились — существующие записи (origin-check [security.md](security.md) не пополнились — существующие записи (origin-check
fallback, отсутствие CSRF-токенов, send-log gap) покрывают всё найденное. fallback, отсутствие CSRF-токенов, ложно-отрицательный `bounced` при сверке
Сводка ревизии — в записи `Security` CHANGELOG `[Unreleased]`. с `postqueue`) покрывают всё найденное.
Сводка ревизии — в записи `Security` CHANGELOG `[0.5.0]`.
+5 -4
View File
@@ -38,7 +38,7 @@ compose неделю ссылается на несуществующий обр
**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт: **Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт:
уникального содержания в нём нет, § D (предрелизная ревизия безопасности) уникального содержания в нём нет, § D (предрелизная ревизия безопасности)
продублирован в [security.md](security.md) и CHANGELOG `[Unreleased]/Security`, продублирован в [security.md](security.md) и CHANGELOG `[0.5.0]/Security`,
а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только а разделы 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 ## Роль администратора домена — кандидат на 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) относит «несколько пользователей **Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
панели, роли» к out of scope (один администратор), поэтому появление второго панели, роли» к out of scope (один администратор), поэтому появление второго
@@ -172,8 +172,9 @@ README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний т
## Разбиение `internal/web` на подпакеты — кандидат на 2.x ## Разбиение `internal/web` на подпакеты — кандидат на 2.x
**Что это.** `internal/web` — самый крупный пакет проекта: 47 файлов, ~3030 **Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
строк, в одной плоскости лежат хендлеры всех разделов панели, сессии, (включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов. security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели. панели.