From 1bd71c10d0d709f3612630ae9045397821567b86 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Fri, 14 Aug 2026 19:36:24 +0300 Subject: [PATCH] docs: align operator docs with routes, sessions, and restore Fix architecture route RBAC, restore Resync, and session/password wording; correct guide restore/session/PTR claims and README port-587 text; point the CSRF ADR at authz.go for global route gating. Co-authored-by: Cursor --- CHANGELOG.md | 10 ++++++++ README.md | 8 +++---- docs/architecture.md | 48 +++++++++++++++++++++++---------------- docs/guide.md | 54 +++++++++++++++++++++++++++----------------- docs/security.md | 2 +- 5 files changed, 77 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab6da6..d54502b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- docs: operator and as-built docs aligned with the code after a full + pass — [architecture.md](docs/architecture.md) route table now marks + **global** routes (404 for domain administrators) and documents the + one-time restore Resync; session/password and restore-session wording + corrected in [guide.md](docs/guide.md) and architecture (own-password change + vs admin reset, no "logout everywhere", immediate session restore, PTR cache + ≈1 min, decrypt has no version check); [README.md](README.md) port-587 and + quick-start volume wording fixed; [security.md](docs/security.md) CSRF ADR + points at `authz.go` for route gating. No behaviour change. + - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance administration**, and **Domain administration** — DNS setup, operations, rate limiting, and backup sections follow the instance/domain boundary diff --git a/README.md b/README.md index 4c03d5d..2f1ca00 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ send log and DNS checks in the panel, encrypted backups. - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Full-server backup and single-domain export/import (optional password encryption) -- Single Docker image; data in a `./data` bind mount +- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead) ## Documentation @@ -136,9 +136,9 @@ DNS — lives in the operator guide's [Full deployment](docs/guide.md#full-deployment) section, with proxy-specific commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). -The compose file maps **465** (always) and **587** (when -`SUBMISSION_ENABLE=true`); bump the pinned image tag deliberately when -upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional +The compose file always publishes **465** and **587**; Postfix listens on 587 +only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the +pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables](docs/guide.md#environment-variables). diff --git a/docs/architecture.md b/docs/architecture.md index c620700..018e2ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,10 @@ state for an older message and the page reports it as such, not as a failure. ## Panel HTTP surface Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated -unless noted. The table below is a summary — HTMX fragment endpoints +unless noted. Routes marked **global** return **404** for domain administrators +(`requireGlobal()` in +[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). 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`. @@ -165,17 +168,17 @@ unless noted. The table below is a summary — HTMX fragment endpoints | `/setup/*` | One-time admin bootstrap | | `/login`, `/logout` | Session auth | | `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | -| `/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 (page also hosts the import form) | -| `/settings` | Admin username/password and DMARC report address | -| `/users`, `/users/*` | Panel user CRUD (global admin only) | +| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network | +| `/domains` | Domain list; `POST /domains` (add domain) is **global** | +| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | +| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | +| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | +| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view | +| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | +| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | +| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | +| `/settings` | Username/password for any user; DMARC report default is **global** only | +| `/users`, `/users/*` | **Global.** Panel user CRUD | HTMX polling refreshes monitoring fragments (5 s while the operator is active on the page, 30 s when the tab is visible but idle, none when hidden — scheduled in @@ -194,13 +197,16 @@ holds the cookie works after process restart, redeploy, or full backup restore. absolute cap (regular use keeps the session alive indefinitely). - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). -- **Password change** — all other sessions are deleted; the current session stays - active ([internal/store/sessions.go](../internal/store/sessions.go), +- **Password change on `/settings`** — changing your own password deletes + every other session for that user; the current session stays active + ([internal/store/sessions.go](../internal/store/sessions.go), [handlers_settings.go](../internal/web/handlers/handlers_settings.go)). + A global administrator resetting another user's password on `/users` updates + the hash but does not delete that user's existing sessions. -Restoring an **older** backup also restores session rows: a session invalidated -after that backup was taken can become valid again if the browser still has the -cookie and idle timeout has not expired. +Restoring an **older** backup also restores session rows: a session removed +after that backup was taken can become valid again if the browser still holds +the cookie and the restored row's `expires_at` has not passed. --- @@ -295,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue `mail.log` via logrotate (14 rotated files, check every 6h, rename + `postfix reload` in `postrotate` — see § Log tailer above). -**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of +**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of `/data` tree, minus `log/`, the setup token and any `tls/`; version check on -restore. Stopped-container `tar` of `./data` is safe (see guide). +restore. On the first successful boot after restore, the panel runs one +**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from +SQLite and both daemons are reloaded, so drift between the extracted archive +and the database is healed before mail flows (same step as `POST /reload` on +demand). Stopped-container `tar` of `./data` is safe (see guide). **Optional encryption** of the two secret-bearing downloads ([internal/secretfile](../internal/secretfile/secretfile.go)): password → diff --git a/docs/guide.md b/docs/guide.md index aa8af16..1ca7208 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -329,9 +329,9 @@ the panel shows after manual edits under `/data`. ### Settings -`/settings` changes the administrator username and/or password, and the -panel-wide default DMARC report address (`rua=`) offered when a domain -doesn't set its own — see +`/settings` changes the signed-in user's username and/or password. **Global +administrators** also set the panel-wide default DMARC report address (`rua=`) +offered when a domain doesn't set its own — see [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins are separate and are not changed here. @@ -344,8 +344,9 @@ There are two roles: including Users, Backup, Status, Mail queue, and System log. - **Domain-admin** — scoped to one or more domains assigned by a global administrator. Sees only those domains' pages, applications, and - Deliveries rows; `/users`, `/backup`, `/status`, `/mail-queue`, and - `/system-log` are not reachable (404). A domain-admin can *export* the + Deliveries rows; cannot add or delete domains. `/users`, `/backup`, + `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not + reachable (404). A domain-admin can *export* the domains assigned to them — see [Exporting and importing a single domain](#exporting-and-importing-a-single-domain). @@ -360,8 +361,10 @@ seven days) with no absolute lifetime cap — an admin who keeps using the panel stays signed in indefinitely. HTMX polling on the monitoring screens (Deliveries, Mail queue, System log, and the Status health fragment) does **not** count as activity, so an auto-refreshing tab left open will not keep a -session alive forever. Changing the password signs out every other session but -leaves the current browser signed in. +session alive forever. Changing **your own** password on `/settings` signs out +every other session for that user but leaves the current browser signed in. +Signing out (`POST /logout`) ends only the current session — other browsers or +tabs for the same user keep working until their session rows expire. ### Upgrading @@ -389,8 +392,8 @@ from whoever assigns the IP (hosting provider's panel/support), not from your own DNS zone. The [Status](#status) page verifies the server's hostname against this -record (forward-confirmed reverse DNS). Results are cached for a few -minutes; use *Re-check* right after publishing a record. +record (forward-confirmed reverse DNS). Results are cached for about one +minute; use *Re-check* right after publishing a record. Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). @@ -427,7 +430,11 @@ starting a container of the **exact same image version** that created it — SelfPost refuses to start otherwise and tells you which tag to use. On the first successful start after restore, `manifest.json` from the archive is **deleted** — it guards only that one boot, so a later in-place upgrade is -not blocked. This is why the compose file pins a fixed tag rather than +not blocked. On that same first boot the panel also runs one **Resync** — +OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both +daemons are reloaded, healing any drift between the extracted files and the +database (the Status page's *Reload configuration* button runs the same step +on demand). This is why the compose file pins a fixed tag rather than `:latest`: without a known version, there'd be no way to tell which image restoring a given backup actually requires (see [Fixed image tag](#fixed-image-tag)). @@ -475,8 +482,8 @@ belong to the old IP/host and have to be reissued for the new one; nothing in the backup carries them. **Restoring an encrypted (`.spbk`) backup** needs a running container to -decrypt it first — an empty first-boot container works, any matching or -newer version, since decryption doesn't touch `/data`. Start one normally +decrypt it first — any container with the `selfpost-backup` CLI works; decryption +does not read `/data` and performs no version check. Start one normally (step 5, but on an empty `/data` you haven't unpacked yet), then: ```sh @@ -488,11 +495,14 @@ resulting `.tar.gz` — see [Encrypting a backup or export](#encrypting-a-backup-or-export) for the decrypt command's password options. -Restoring an archive taken **before** you invalidated a session (password -change, logout everywhere) can bring that session back: session rows travel -with the backup, and a browser that still holds the matching cookie is -logged in again once the idle timeout allows it. If a restore might do this, -changing every user's password afterwards clears it out. +Restoring an archive taken **before** a session row was removed can bring +that session back: session rows travel with the backup, and a browser that +still holds the matching cookie is signed in again on the next request if the +restored row's idle expiry has not passed. `POST /logout` removes only the +current session; there is no "logout everywhere". Changing your own password +on `/settings` deletes your other sessions, but a global administrator +resetting another user's password on `/users` does not invalidate that user's +existing sessions. **Alternative: archive `./data` while stopped.** If the service can be taken offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on @@ -559,12 +569,14 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has. ### Domains page -`/domains` adds sending domains, and shows each domain's DKIM TXT value, -SPF/DMARC checks, and SASL applications. Per-domain rate limits (level 2) and -trusted-IP application overrides are configured here — see [Rate limiting — +`/domains` lists sending domains and hosts the add-domain form (**global +administrator only**). Domain administrators see only domains assigned to +them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL +applications. Per-domain rate limits (level 2) and trusted-IP application +overrides are configured here — see [Rate limiting — level 2](#rate-limiting--level-2-domain-and-application). *Export domain* writes a single-domain archive; *Import a domain* on the Backup page reads -one back in — see [Exporting and importing a single +one back in (**global administrator only**) — see [Exporting and importing a single domain](#exporting-and-importing-a-single-domain). ### Domain-level DNS (SPF, DKIM, DMARC) diff --git a/docs/security.md b/docs/security.md index a54ed40..64c2305 100644 --- a/docs/security.md +++ b/docs/security.md @@ -198,7 +198,7 @@ domain-admin's browser sending a request still needs that domain-admin's own cookie, so a token would not add a boundary between roles that the authorization checks (`Principal.CanAccessDomain`, [internal/web/auth/principal.go](../internal/web/auth/principal.go); route -gating in [internal/web/auth/middleware.go](../internal/web/auth/middleware.go)) +gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go)) don't already enforce. The remaining case is an external site making a logged-in user's browser send a request, which the origin check covers without touching a single template.