docs: align operator docs with routes, sessions, and restore
test / test (push) Has been cancelled

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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 19:36:24 +03:00
parent 74acf09212
commit 1bd71c10d0
5 changed files with 77 additions and 45 deletions
+10
View File
@@ -84,6 +84,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### 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 - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
administration**, and **Domain administration** — DNS setup, operations, administration**, and **Domain administration** — DNS setup, operations,
rate limiting, and backup sections follow the instance/domain boundary rate limiting, and backup sections follow the instance/domain boundary
+4 -4
View File
@@ -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 - 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 - 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) - 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 ## Documentation
@@ -136,9 +136,9 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file maps **465** (always) and **587** (when The compose file always publishes **465** and **587**; Postfix listens on 587
`SUBMISSION_ENABLE=true`); bump the pinned image tag deliberately when only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional 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 (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables). variables](docs/guide.md#environment-variables).
+29 -19
View File
@@ -154,7 +154,10 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated 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`, (`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`. `/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 | | `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth | | `/login`, `/logout` | Session auth |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | | `/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 | | `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/import` | Domain import (`POST`; form on the Backup page) | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/deliveries` | Send log with filters | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue` | Postfix queue view | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/system-log` | `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Admin username/password and DMARC report address | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | Panel user CRUD (global admin only) | | `/users`, `/users/*` | **Global.** Panel user CRUD |
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
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in 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). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **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)). (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change** — all other sessions are deleted; the current session stays - **Password change on `/settings`** — changing your own password deletes
active ([internal/store/sessions.go](../internal/store/sessions.go), 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)). [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 Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still has the after that backup was taken can become valid again if the browser still holds
cookie and idle timeout has not expired. 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 + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `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 `/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 **Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password → ([internal/secretfile](../internal/secretfile/secretfile.go)): password →
+33 -21
View File
@@ -329,9 +329,9 @@ the panel shows after manual edits under `/data`.
### Settings ### Settings
`/settings` changes the administrator username and/or password, and the `/settings` changes the signed-in user's username and/or password. **Global
panel-wide default DMARC report address (`rua=`) offered when a domain administrators** also set the panel-wide default DMARC report address (`rua=`)
doesn't set its own — see offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
are separate and are not changed here. are separate and are not changed here.
@@ -344,8 +344,9 @@ There are two roles:
including Users, Backup, Status, Mail queue, and System log. including Users, Backup, Status, Mail queue, and System log.
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; `/users`, `/backup`, `/status`, `/mail-queue`, and Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/system-log` are not reachable (404). A domain-admin can *export* the `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
reachable (404). A domain-admin can *export* the
domains assigned to them — see domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain). [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 stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does (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 **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 session alive forever. Changing **your own** password on `/settings` signs out
leaves the current browser signed in. 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 ### Upgrading
@@ -389,8 +392,8 @@ from whoever assigns the IP (hosting provider's panel/support), not from
your own DNS zone. your own DNS zone.
The [Status](#status) page verifies the server's hostname against this The [Status](#status) page verifies the server's hostname against this
record (forward-confirmed reverse DNS). Results are cached for a few record (forward-confirmed reverse DNS). Results are cached for about one
minutes; use *Re-check* right after publishing a record. minute; use *Re-check* right after publishing a record.
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). [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 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 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 **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 `:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)). 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. the backup carries them.
**Restoring an encrypted (`.spbk`) backup** needs a running container to **Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — an empty first-boot container works, any matching or decrypt it first — any container with the `selfpost-backup` CLI works; decryption
newer version, since decryption doesn't touch `/data`. Start one normally 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: (step 5, but on an empty `/data` you haven't unpacked yet), then:
```sh ```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 export](#encrypting-a-backup-or-export) for the decrypt command's password
options. options.
Restoring an archive taken **before** you invalidated a session (password Restoring an archive taken **before** a session row was removed can bring
change, logout everywhere) can bring that session back: session rows travel that session back: session rows travel with the backup, and a browser that
with the backup, and a browser that still holds the matching cookie is still holds the matching cookie is signed in again on the next request if the
logged in again once the idle timeout allows it. If a restore might do this, restored row's idle expiry has not passed. `POST /logout` removes only the
changing every user's password afterwards clears it out. 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 **Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on 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 page
`/domains` adds sending domains, and shows each domain's DKIM TXT value, `/domains` lists sending domains and hosts the add-domain form (**global
SPF/DMARC checks, and SASL applications. Per-domain rate limits (level 2) and administrator only**). Domain administrators see only domains assigned to
trusted-IP application overrides are configured here — see [Rate limiting — 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* level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads 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](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC) ### Domain-level DNS (SPF, DKIM, DMARC)
+1 -1
View File
@@ -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 cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`, authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route [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 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 logged-in user's browser send a request, which the origin check covers without
touching a single template. touching a single template.