docs: the panel as it is in 2.0
Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.
- guide.md: a section on the panel - menu, paths, the roles global and
domain with outbound and inbound assigned separately -, Overview and
Health, Queue and System log, Account and Server > Settings, the
domain page / Domain settings / application form, Help as a page of
topics. The DMARC report address is one of three choices set in
Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
variables the panel reads, and the filter's socket on Health.
Sessions; backup and export encrypted unless the box is unticked;
1.x data is not restored into 2.0.
- architecture.md: Panel HTTP surface rewritten from web.go (routes,
the /server/ guard, per-object checks, <page>/fragment, one layout,
typed page data, the kit and its guards, CSP and htmx); Persistence
gains the database - one baseline, a 1.x file refused, the tables as
they are.
- security.md: roles and what each reaches, as built; sessions by user
id and what ends them; state-changing requests; errors shown to
users; the 2.0 review; two accepted risks from it - a delegated
inbound upstream may be any host and port, and Account's profile is
saved without the password.
- CHANGELOG: Removed opens with "2.0 starts from an empty data
directory"; entries that describe the user-default report address
say they were replaced before release; the filter's socket.
- roadmap.md: two review follow-up rows struck as retired by the
redesign (.pair / .split, the two back links); password-reset notes
that changing the account e-mail must then ask for the password.
Progress 25/27.
- development.md: the component kit and its guards; README and
product.md: wording.
Left as they are
- The third follow-up row the plan expected to strike - initShowWhen
is not re-run after an htmx swap - still holds: no polled fragment
carries such a control today, so nothing breaks, but the redesign did
not retire it.
- guide.md embeds no screenshots, so none needed retaking.
- README and guide still name image 1.9.5 - the version cut.
- plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
paths; roadmap's template-data-typing is largely done by the
redesign.
go test -count=1 ./cmd/panel (env-doc tables): ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+272
-59
@@ -114,10 +114,10 @@ One process, four roles:
|
||||
parameters (`queue_run_delay`, `minimal_backoff_time`,
|
||||
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
|
||||
`delay_warning_time`) and caches the snapshot on the handlers config. The
|
||||
Mail queue card and a delivery's `deferred` / `bounced` history print those
|
||||
Outbound queue box and a delivery's `deferred` / `bounced` history print those
|
||||
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
|
||||
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||
@@ -133,8 +133,8 @@ One process, four roles:
|
||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||
4. **rate-limit recalc** — every six hours (and on demand from the domain
|
||||
page), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
|
||||
@@ -159,7 +159,7 @@ panel user cannot read). `follow()` drains the old inode once more before
|
||||
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||
an error.
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, migration `0003`): the
|
||||
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the
|
||||
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
|
||||
on start resumes from it, parsing the tail written while the panel was down. If
|
||||
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||
@@ -185,8 +185,8 @@ rotated past fourteen files while the panel was down, or deleted.
|
||||
|
||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||
finding a message's lines means reading rather than seeking — and matches the id
|
||||
anchored on the character before it, since queue ids are hexadecimal runs and a
|
||||
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||
@@ -197,61 +197,227 @@ 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. 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`.
|
||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
|
||||
two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
|
||||
included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
|
||||
building them only takes method values, so the route guard test can run it on
|
||||
a bare `Server` and ask which pattern answers a path:
|
||||
|
||||
| Route | Purpose |
|
||||
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
|
||||
`/static/`, `/setup/`, `/login`, `/logout`;
|
||||
- **authenticated** — everything else, behind `RequireAuth`: a request without
|
||||
a live session is redirected to `/login`.
|
||||
|
||||
Both sit inside `secure()`, which sets the security headers and runs the origin
|
||||
check on every state-changing request (see below).
|
||||
|
||||
### Routes
|
||||
|
||||
A path reads like the menu: `/<group>/<page>`, an entity under its list, an
|
||||
action under the thing it changes. Every state change is a `POST`; a `GET`
|
||||
only reads (the delete confirmation at `GET …/delete` renders a form).
|
||||
|
||||
| Group | Routes |
|
||||
|---|---|
|
||||
| `/healthz` | Liveness (no auth) |
|
||||
| `/license` | Embedded `LICENSE` text (no auth) |
|
||||
| `/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`, `/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; retry-policy card on the page (not the HTMX fragment) |
|
||||
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
||||
| `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) |
|
||||
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||
| `/help` | In-panel operator help (any authenticated user) |
|
||||
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
||||
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
|
||||
| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. |
|
||||
| Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) |
|
||||
| Overview | `GET /overview`, `GET /overview/fragment` |
|
||||
| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
|
||||
| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
|
||||
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
|
||||
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
|
||||
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
|
||||
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
|
||||
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
|
||||
| User menu | `GET /help` · `POST /logout` (public mux) |
|
||||
|
||||
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
|
||||
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||
activity). The Mail queue retry-policy card is outside that fragment: it is the
|
||||
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
|
||||
not a live re-read.
|
||||
An application is addressed under its domain, and an application id that does
|
||||
not belong to the domain in the path is a 404. The application form is one
|
||||
`POST`: sender, client IPs and rate limit are validated and saved together or
|
||||
not at all, also at creation. The page that shows a new password is the
|
||||
response to the `POST` that created or regenerated it; it has no `GET` path and
|
||||
carries `Cache-Control: no-store`.
|
||||
|
||||
The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`,
|
||||
`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`,
|
||||
`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect):
|
||||
each answers 404 once signed in. `TestRoutesFollowNavigation`
|
||||
([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go))
|
||||
holds the table: every path is registered with its methods, every menu entry
|
||||
points at a registered path, every old path answers 404, and no template
|
||||
contains an old path.
|
||||
|
||||
### Who may open what
|
||||
|
||||
Authorization is by tree where the tree is the boundary, and by object where it
|
||||
is not.
|
||||
|
||||
- **`/server/` is guarded once.** Routes under it are registered through
|
||||
`server()` in `web.go`, which wraps the handler in `globalOnly` (404 for
|
||||
anyone but the global role — the answer a missing page gets, so the panel does
|
||||
not confirm the page exists) and panics at start-up for a pattern outside
|
||||
`/server/`. The handlers also call `requireGlobal()` themselves.
|
||||
- **Overview and the Outbound queue** are global-only but not under `/server/`,
|
||||
so their handlers call `requireGlobal()`.
|
||||
- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers
|
||||
404 for a missing domain and for one the principal may not reach alike
|
||||
([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go));
|
||||
application routes go on to check the application belongs to that domain.
|
||||
Lists and the log are filtered by assignment in the query
|
||||
(`ListDomainsForUser`), not in the template. Adding and deleting a domain is
|
||||
`requireGlobal()`.
|
||||
- **Inbound is checked per inbound domain**, separately from outbound
|
||||
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
|
||||
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
|
||||
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
|
||||
All three answer 404 alike.
|
||||
- **The DMARC hub** is open to anyone with outbound reach for the reports of
|
||||
their own domains; the *Ingest* box (server-wide statistics) is rendered for
|
||||
the global role only.
|
||||
- **Account and Help** are open to every signed-in user.
|
||||
- The principal ([web/auth/principal.go](../internal/web/auth/principal.go))
|
||||
carries the role, the assigned outbound and inbound domain ids and the two
|
||||
*All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only
|
||||
predicates. The reach, not the role, decides which menu groups exist.
|
||||
|
||||
Routes marked global are tested per path for a user with the *domain* role,
|
||||
including another tenant's domain id
|
||||
([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go),
|
||||
[authz_test.go](../internal/web/handlers/authz_test.go)).
|
||||
|
||||
### Polled fragments
|
||||
|
||||
A polled page answers its refresh at `<page>/fragment` (`/overview/fragment`,
|
||||
`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the
|
||||
same `{{define}}` block that renders the page's own box, so the first paint and
|
||||
a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
|
||||
active on the page, every 30 s when the tab is visible but idle, and not at all
|
||||
when it is hidden — scheduled in `panel.js` via `data-poll`, not
|
||||
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
|
||||
Function`, which the CSP forbids. Polling does not extend the session idle
|
||||
timeout (only non-`HX-Request` `GET`s and mutating requests count as
|
||||
activity). The Outbound queue's retry-policy box is outside the polled region:
|
||||
it is the start-up `postconf -h` snapshot (see [Panel
|
||||
binary](#panel-binary-cmdpanel)), not a live re-read.
|
||||
|
||||
### View engine and component kit
|
||||
|
||||
Pages are rendered by [internal/web/view](../internal/web/view/view.go). There
|
||||
is one layout and one way to render.
|
||||
|
||||
- **One layout.** Every page is parsed with `layout.html` (the shell: brick
|
||||
navbar with the wordmark, the perforated edge, the strip of sibling pages,
|
||||
user menu, footer) and `components.html` (the partials), then its own file.
|
||||
`layout_signed_out` is the same document without navigation, used for sign-in
|
||||
and setup; `Engine.Render` picks it when the page data names no user. The
|
||||
page's file defines `content`; `pageFiles` in `view.go` lists the files of
|
||||
each page, and fragments are listed in `fragmentFiles` and rendered by
|
||||
`RenderFragment` with no layout.
|
||||
- **Typed page data.** A page's data is a struct that embeds `Meta` (title,
|
||||
user, the viewer's reach, the menu group and page it sits in) and carries
|
||||
the page's boxes and rows as fields, built by a constructor in the `view`
|
||||
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
|
||||
derived from `Meta` and the feature flags (`SetInboundEnabled`,
|
||||
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
|
||||
has is decided once. No template reads a `map[string]any`; the one map left
|
||||
in the handlers is the health sampler's, which is turned into a typed
|
||||
`Health` before rendering.
|
||||
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
|
||||
holds one `{{define}}` per partial (page head, postmark, box and its head and
|
||||
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
|
||||
empty state, confirm list, help topic, form rows) and is the only file that
|
||||
writes their `sp-` markup. [components.go](../internal/web/view/components.go)
|
||||
is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …),
|
||||
so a page passes a struct, never a `dict`. Template functions: `status_tag`
|
||||
(a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a
|
||||
table cell). A page that needs a component the kit lacks is a design change
|
||||
made in the mockups first
|
||||
([plans/panel-redesign.md](plans/panel-redesign.md) § The contract).
|
||||
- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data:
|
||||
topics grouped by section of the panel. A box's head links to its topic with
|
||||
`Box.Help` (`/help#<topic>`), and the page lists only the
|
||||
sections the viewer's menu shows.
|
||||
- **Assets.** [static/](../internal/web/view/static) is embedded
|
||||
(`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed),
|
||||
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
|
||||
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
|
||||
and `/static/` does not list its files. The kit page
|
||||
`GET /server/components` renders every partial in every state from fixtures
|
||||
(global role only).
|
||||
|
||||
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
|
||||
They read their rules from the accepted mockups
|
||||
([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather
|
||||
than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the
|
||||
`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the
|
||||
components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract`
|
||||
(no `!important`, no id or page selector, type floor, widths),
|
||||
`TestTemplatesUseComponents` (no hand-written component markup in a page),
|
||||
`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page
|
||||
has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
|
||||
`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`,
|
||||
`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The
|
||||
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
|
||||
the CI step
|
||||
[design-first](../.github/scripts/design-first.sh) rejects a commit that
|
||||
changes a guard together with the code it judges, and a template that is a
|
||||
byte-for-byte return of one from history. The design contract and the process
|
||||
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
|
||||
|
||||
### Security headers, CSP and scripts
|
||||
|
||||
`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these
|
||||
on every response: `Content-Security-Policy: default-src 'self'; object-src
|
||||
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
||||
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
|
||||
is `Secure`. Because the policy has no exemptions, no template carries an
|
||||
inline `<script>`, an event-handler attribute or `style="…"` — the page's
|
||||
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
|
||||
`data-confirm` prompt on destructive forms, conditional field visibility,
|
||||
adaptive polling), both optional: every form works as a native HTML `POST`
|
||||
with scripts off. htmx is told not to inject its indicator stylesheet
|
||||
(`htmx-config` meta), which the CSP would otherwise have to allow.
|
||||
|
||||
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
|
||||
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
|
||||
panel's host; a request with neither header is let through (an accepted risk).
|
||||
There are no CSRF tokens — see [security.md](security.md).
|
||||
|
||||
### Errors shown to users
|
||||
|
||||
A validation refusal is shown as the text the validator wrote (on the form,
|
||||
next to the field, or in a notice above the first box). Anything else — a
|
||||
database error, `saslpasswd2`, a Postfix reload — is logged
|
||||
and the page gets a fixed sentence saying so; the error text never reaches the
|
||||
browser. A template error renders a bare `500` rather than a half-written page
|
||||
(`Engine.Render` buffers).
|
||||
|
||||
### Sessions
|
||||
|
||||
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
|
||||
token; the database stores **SHA-256 of the token**, not the token itself — a
|
||||
stolen DB or backup archive does not alone grant login, but a browser that still
|
||||
holds the cookie works after process restart, redeploy, or full backup restore.
|
||||
Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
|
||||
holds a random token; the database stores **SHA-256 of the token**, not the
|
||||
token itself — a stolen DB or backup archive does not alone grant login, but a
|
||||
browser that still holds the cookie works after process restart, redeploy, or
|
||||
full backup restore.
|
||||
|
||||
- **A session belongs to a user by id** (`sessions.user_id`, `ON DELETE
|
||||
CASCADE`). Deleting a user ends their sessions; renaming one keeps them;
|
||||
`RequireAuth` loads the user on every request, so a session whose user is
|
||||
gone is a redirect to `/login`.
|
||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||
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 on `/settings`** — changing your own password deletes
|
||||
every other session for that user; the current session stays active
|
||||
- **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
|
||||
hour (`renewThreshold` in
|
||||
[internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||
- **Password change on Account** — deletes every other session of that user;
|
||||
the current one 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.
|
||||
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
||||
- **A password set by a global user on the user form** deletes all of that
|
||||
user's sessions (`DestroyUserSessions`); set for oneself, it keeps the
|
||||
current one
|
||||
([handlers_users.go](../internal/web/handlers/handlers_users.go),
|
||||
`endSessionsAfterPasswordSet`).
|
||||
|
||||
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
|
||||
@@ -264,7 +430,7 @@ the cookie and the restored row's `expires_at` has not passed.
|
||||
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,
|
||||
`store` directly for single-table reads and simple writes (sessions, users,
|
||||
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
|
||||
@@ -295,7 +461,7 @@ flowchart TB
|
||||
dmarcSvc["internal/dmarc"]
|
||||
end
|
||||
subgraph persistence ["Persistence"]
|
||||
store["internal/store — SQLite, embedded migrations"]
|
||||
store["internal/store — SQLite, embedded schema"]
|
||||
end
|
||||
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
||||
postfix["internal/postfix"]
|
||||
@@ -344,7 +510,7 @@ single-connection trade-off that follows from it.
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) |
|
||||
| `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
|
||||
| `setup-token` | First-run setup token file |
|
||||
| `opendkim/` | DKIM keys + tables |
|
||||
| `sasl/sasldb2` | Application SASL credentials |
|
||||
@@ -363,6 +529,49 @@ single-connection trade-off that follows from it.
|
||||
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||
full backups also archive the operator's `./certs` PEM files when present.
|
||||
|
||||
### Database
|
||||
|
||||
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
|
||||
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
|
||||
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
|
||||
chain. **2.0 starts from an empty data directory:** a database with a schema
|
||||
version but without the 2.x `application_id` — what every 1.x file looks like —
|
||||
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
|
||||
version is newer than the build knows. A restored 1.x backup fails the same
|
||||
way. Domains are carried across with a domain export, whose format is not the
|
||||
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
|
||||
released the baseline is never edited. History and the per-table list:
|
||||
[schema-migrations.md](schema-migrations.md).
|
||||
|
||||
The tables that carry the panel's model:
|
||||
|
||||
- **`users`** — `role` is `global` or `domain` (a reach, not a rank); `email` is
|
||||
the user's own address, edited on Account and set by the global role on the
|
||||
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
|
||||
every domain of that direction, including ones added later (with a flag set
|
||||
the assignment rows are ignored).
|
||||
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
|
||||
domains assigned to a *domain* user. Separate tables: the same name on both
|
||||
is two assignments, and a sending domain gives no inbound access or the other
|
||||
way round.
|
||||
- **`sessions`** — `token_hash`, `user_id` (`ON DELETE CASCADE`), `expires_at`.
|
||||
Keyed by user id, not by name.
|
||||
- **`domains`** — `dmarc_rua` is the whole of a domain's report-address choice:
|
||||
`''` is no reports (a new domain), the address SelfPost derives for the
|
||||
domain and the server's hostname is the hosted choice, anything else is an
|
||||
address typed for the domain. Nothing refers to a user, and deleting one
|
||||
changes no domain.
|
||||
- **`settings`** — key/value, what holds for the instance whoever is signed in
|
||||
(send-log retention). Nothing that belongs to a user.
|
||||
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
|
||||
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
|
||||
`logtail_state`, `inbound_domains`, `inbound_transports`,
|
||||
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
|
||||
|
||||
The panel is the only writer; SQLite runs in WAL mode on one connection (see
|
||||
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
|
||||
with `VACUUM INTO` instead of copying the file.
|
||||
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||
@@ -377,7 +586,8 @@ tables, Postfix's sender map, inbound relay maps (when
|
||||
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
|
||||
are re-derived from SQLite and both daemons are reloaded, so drift between the
|
||||
extracted archive and the database is healed before mail flows. Manual
|
||||
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and
|
||||
`POST /server/health/reload` (the *Reload configuration* button on Server ›
|
||||
Health) resyncs OpenDKIM, the sender map, and
|
||||
inbound maps only — not DMARC maps. Stopped-container
|
||||
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||
|
||||
@@ -386,9 +596,12 @@ inbound maps only — not DMARC maps. Stopped-container
|
||||
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
|
||||
its counter and an end-of-stream flag (so truncation and reordering fail to
|
||||
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
||||
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
|
||||
default. Domain import detects the envelope by magic bytes; an encrypted full
|
||||
backup is converted back with `selfpost-backup -decrypt` before restore.
|
||||
`.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
|
||||
password* box is ticked by default on both downloads and the plain form is
|
||||
what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is
|
||||
given a password. Domain import detects the envelope by magic bytes; an
|
||||
encrypted full backup is converted back with `selfpost-backup -decrypt` before
|
||||
restore.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+27
-1
@@ -352,6 +352,30 @@ go test ./...
|
||||
— 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)).
|
||||
|
||||
### Panel pages: the component kit and its guards
|
||||
|
||||
Every panel page is built from the component kit in
|
||||
[internal/web/view](../internal/web/view): partials in
|
||||
`templates/components.html`, their typed input in `components.go`, one stylesheet
|
||||
`static/panel.css`, one layout `layout.html`. A page template calls partials with
|
||||
a struct from a `view.New…` constructor and writes no component markup of its
|
||||
own ([architecture.md § Panel HTTP surface](architecture.md#panel-http-surface)).
|
||||
The guard tests (`internal/web/guard_*_test.go`, `internal/web/view/guard_*_test.go`)
|
||||
run in `go test ./...` and read their rules from the accepted mockups under
|
||||
[docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html): which
|
||||
classes and icons exist, what `panel.css` may contain, what component skeleton
|
||||
each page has, which routes exist. A failing guard means the page is wrong, not
|
||||
the guard.
|
||||
|
||||
Changing the design is mockup first: edit the fragment under `panel/src/`, run
|
||||
`build.py` and `check.py --write`, commit as `design: …`, get it accepted, and
|
||||
only then touch `internal/web`. CI enforces the order with `design-first`
|
||||
([.github/scripts/design-first.sh](../.github/scripts/design-first.sh)): a commit
|
||||
that changes the mockups, a guard or the script touches nothing else under
|
||||
`internal/web/`, and a template that returns from git history is refused.
|
||||
[plans/panel-redesign.md](plans/panel-redesign.md) § The contract and
|
||||
§ Enforcement have the full rules.
|
||||
|
||||
### End-to-end (container suite)
|
||||
|
||||
Separate Go module `test/e2e/`; **not** included in the main module's
|
||||
@@ -391,7 +415,9 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||||
|
||||
### `test.yml` — every push and PR to `main`
|
||||
|
||||
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
|
||||
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
|
||||
`go test ./...` (main module, no e2e; includes the panel guard tests, see
|
||||
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)).
|
||||
|
||||
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||
|
||||
|
||||
+327
-185
@@ -6,9 +6,15 @@ overview and quick start, see [README.md](../README.md).
|
||||
This guide has three parts: **[Installation](#installation)** (getting a
|
||||
container running with a working reverse proxy and TLS), **[Instance
|
||||
administration](#instance-administration)** (running and maintaining the
|
||||
SelfPost server itself — status, backups, users, upgrades), and **[Domain
|
||||
administration](#domain-administration)** (day-to-day work on the sending
|
||||
domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||
SelfPost server itself — the panel's layout, health, backups, users,
|
||||
upgrades), and **[Domain administration](#domain-administration)** (day-to-day
|
||||
work on the sending domains hosted on that instance — DNS, the outbound log,
|
||||
rate limits, applications).
|
||||
|
||||
SelfPost 2.0 starts from an empty data directory: a `/data` written by 1.x is
|
||||
refused at start, not upgraded. A domain moves over with its
|
||||
[export file](#exporting-and-importing-a-single-domain), and every panel path
|
||||
changed (see the CHANGELOG, *Removed*).
|
||||
|
||||
## Table of contents
|
||||
|
||||
@@ -21,8 +27,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
||||
- [Instance administration](#instance-administration)
|
||||
- [Status](#status)
|
||||
- [Mail queue and System log](#mail-queue-and-system-log)
|
||||
- [The panel](#the-panel)
|
||||
- [Help](#help)
|
||||
- [Overview and Health](#overview-and-health)
|
||||
- [Queue and System log](#queue-and-system-log)
|
||||
- [Account](#account)
|
||||
- [Settings](#settings)
|
||||
- [Users](#users)
|
||||
- [Sessions](#sessions)
|
||||
@@ -37,11 +46,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||
- [Inbound relay](#inbound-relay)
|
||||
- [DMARC reports](#dmarc-reports)
|
||||
- [Domain administration](#domain-administration)
|
||||
- [Domains page](#domains-page)
|
||||
- [Outbound domains](#outbound-domains)
|
||||
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||||
- [IP warmup](#ip-warmup)
|
||||
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
|
||||
- [Deliveries](#deliveries)
|
||||
- [Outbound log](#outbound-log)
|
||||
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
|
||||
|
||||
## Installation
|
||||
@@ -61,8 +70,8 @@ ports in external scans.
|
||||
|
||||
The [README quick start](../README.md#quick-start) runs a single container
|
||||
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
||||
reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status
|
||||
page will report missing TLS material until you mount PEM files at
|
||||
reverse proxy, no `./certs` bind mount — Postfix still starts, but Overview
|
||||
and Health 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
|
||||
@@ -71,8 +80,8 @@ The one-time setup link is always printed as
|
||||
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,
|
||||
mail queue, system log. **What does not:** reliable outbound delivery to the
|
||||
**What works:** the full panel — setup, domains, applications, outbound log,
|
||||
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
|
||||
on your network, HELO does not match anything receivers trust).
|
||||
|
||||
@@ -98,8 +107,9 @@ On first start the one-time setup URL is printed in the container log
|
||||
container — `./data/setup-token` on the host, mode `0600` — then deleted when
|
||||
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
|
||||
token, not a query string), valid for ten minutes. Open it to choose the
|
||||
administrator username and password — until then the panel has no login. If
|
||||
this host ships container logs to a central aggregator, prefer reading the
|
||||
administrator username and password — until then the panel has no login, and
|
||||
its sign-in page says there is no administrator yet and points at the setup
|
||||
link. If this host ships container logs to a central aggregator, prefer reading the
|
||||
file:
|
||||
|
||||
```sh
|
||||
@@ -164,8 +174,8 @@ cat ./data/setup-token
|
||||
|
||||
**4. DNS and sending.** Before sending real mail:
|
||||
|
||||
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
|
||||
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Server ›
|
||||
Health → *Re-check DNS*) — see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
||||
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
|
||||
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
|
||||
@@ -193,18 +203,18 @@ expected to set; defaults match the code exactly.
|
||||
|---|---|---|---|
|
||||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
|
||||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound › Domains* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
|
||||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *Outbound › DMARC reports*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `20` | `.env` |
|
||||
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. The panel reads it to report on the *Spam filter* box of the inbound pages and to check that the filter answers (Server › Health). | *(empty)* | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). The *Spam filter* box states which. | `accept` | `.env` |
|
||||
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
|
||||
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. | `26214400` (25 MiB) | `.env` |
|
||||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||||
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
||||
|
||||
@@ -311,98 +321,172 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
||||
|
||||
## Instance administration
|
||||
|
||||
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||
service healthy and will mail be accepted?"
|
||||
After sign-in the global role opens on **Overview** — the place to answer "is
|
||||
the service healthy and will mail be accepted?" A user with the *domain* role
|
||||
opens on the first list they reach: Outbound › Domains, or Inbound › Domains
|
||||
when that is all they are assigned.
|
||||
|
||||
### In-panel help
|
||||
### The panel
|
||||
|
||||
The panel carries short operator notes so day-to-day work does not require
|
||||
opening this guide. **Help** in the navigation opens an index; each Status or
|
||||
domain card's «?» opens the same text in a side drawer (CSS only, no extra
|
||||
script). That content explains what a check or control *means* — kernel
|
||||
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate
|
||||
limits, and similar. It is not a second copy of this document: installation,
|
||||
The menu is grouped by mail direction, and a path reads like the menu:
|
||||
`/<group>/<page>`, an entity under its list, an action under the thing it
|
||||
changes.
|
||||
|
||||
| Menu | Pages | Path |
|
||||
|---|---|---|
|
||||
| Overview | Overview | `/overview` |
|
||||
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
|
||||
| Inbound | Domains | `/inbound/domains` |
|
||||
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
|
||||
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
|
||||
|
||||
A page a user cannot reach, or a feature that is off, leaves no gap in the
|
||||
menu: a group with nothing in it is absent. *Inbound* appears only with
|
||||
`INBOUND_RELAY_ENABLE=true` and *DMARC reports* only with
|
||||
`DMARC_REPORTS_ENABLE=true`. Everything under `/server/`, and Overview and the
|
||||
queue, answer 404 to a user with the *domain* role — the same answer a missing
|
||||
page gets.
|
||||
|
||||
Pages that used to be one are now several. The old Status page is **Overview**
|
||||
(one verdict per check) and **Server › Health** (the tables behind them). The
|
||||
old Settings page is **Account** (your own name, e-mail and password) and
|
||||
**Server › Settings** (the instance). A domain has its own page, **Domain
|
||||
settings** under it, and an **application** form of its own under the domain.
|
||||
|
||||
### Help
|
||||
|
||||
**Help** (user menu) is a page of topics, one box per section of the panel —
|
||||
Overview, Outbound, Inbound, Server, Account — and a user is shown the sections
|
||||
their menu shows. The **?** in the head of a box opens the topic about that box
|
||||
on the Help page, scrolled to it. The topics explain what a check
|
||||
or control *means* — kernel counters, TLS on port 465, forward-confirmed PTR,
|
||||
DNS publish hints, rate limits, DMARC report addresses, the spam filter, and
|
||||
similar. They are not a second copy of this document: installation,
|
||||
reverse-proxy setup, backup/restore, environment variables, and security
|
||||
assumptions stay here in `docs/guide.md` (linked from the drawer and Help
|
||||
page).
|
||||
assumptions stay here in `docs/guide.md`.
|
||||
|
||||
### Status
|
||||
### Overview and Health
|
||||
|
||||
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
||||
certificate validity and expiry, milter socket presence, and a short Postfix
|
||||
queue summary. The **Machine** card adds the resource usage of the host
|
||||
underneath — processor (core and thread counts), memory and swap, and
|
||||
per-interface network throughput and totals — read from the kernel's
|
||||
counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning here, because both delay or kill the mail path;
|
||||
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
||||
against the PTR record the internet publishes for this server's IP
|
||||
(forward-confirmed reverse DNS) — see
|
||||
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
|
||||
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
|
||||
Postfix sender map from the database (and inbound relay maps when
|
||||
`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
|
||||
shows after manual edits under `/data`. It does **not** rebuild DMARC Postfix
|
||||
maps; a full restore Resync does (see [Restore](#restore)).
|
||||
**Overview** (`/overview`, global role) shows the stamp — the worst of its
|
||||
checks — and one card per check: machine (processor and memory), supervised
|
||||
processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets
|
||||
and reverse DNS. A check the panel could not run shows as a warning, never as
|
||||
fine. Below the cards are the outbound domain list and, with the inbound relay
|
||||
on, the inbound one. Each card leads to its table on **Server › Health**
|
||||
(`/server/health`) — the queue card to the Outbound queue — and Health has the
|
||||
readings:
|
||||
|
||||
### Mail queue and System log
|
||||
- **Machine** — processor (core and thread counts), memory and swap, and
|
||||
per-interface network throughput and totals, read from the kernel's
|
||||
counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning, because both delay or kill the mail path; throughput is
|
||||
only reported.
|
||||
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
|
||||
sockets** (OpenDKIM and the journal-milter).
|
||||
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
|
||||
record the internet publishes for this server's IP (forward-confirmed
|
||||
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
|
||||
*Re-check DNS* after changing DNS.
|
||||
- **Configuration** — the **Reload configuration** button re-applies OpenDKIM
|
||||
tables and the Postfix sender map from the database (and inbound relay maps
|
||||
when `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the
|
||||
panel shows after manual edits under `/data`. It does **not** rebuild DMARC
|
||||
Postfix maps; a full restore Resync does (see [Restore](#restore)).
|
||||
|
||||
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||
trying to deliver or deferring. A card at the top states this instance's
|
||||
retry policy — first retry delay, later backoff cap, how long a message
|
||||
stays in the queue — from `postconf -h`, read once when the panel starts.
|
||||
A `postconf -e` override inside the container is visible after the next
|
||||
panel (or container) restart. There is no maximum attempt count: Postfix
|
||||
retries until the message is delivered or the queue lifetime runs out.
|
||||
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
||||
related daemon lines). The log rotates daily (14 files kept) with a
|
||||
`postfix reload` after each rotation; a background loop checks every six
|
||||
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.
|
||||
### Queue and System log
|
||||
|
||||
- **Outbound › Queue** (`/outbound/queue`, global role) — live view of
|
||||
messages Postfix is still trying to deliver or deferring, as `postqueue -p`
|
||||
lists them. It only shows: nothing there deletes or forces a message. A box
|
||||
states this instance's retry policy — first retry delay, later backoff cap,
|
||||
how long a message stays in the queue — from `postconf -h`, read once when
|
||||
the panel starts. A `postconf -e` override inside the container is visible
|
||||
after the next panel (or container) restart. There is no maximum attempt
|
||||
count: Postfix retries until the message is delivered or the queue lifetime
|
||||
runs out.
|
||||
- **Server › System log** (`/server/log`) — tail of `/data/log/mail.log`
|
||||
(Postfix and related daemon lines). The log rotates daily (14 files kept)
|
||||
with a `postfix reload` after each rotation; a background loop checks every
|
||||
six 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.
|
||||
|
||||
### Account
|
||||
|
||||
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
|
||||
(username and e-mail) and **Password**. Saving the profile does not ask for the
|
||||
password; changing the password asks for the current one and is rate-limited
|
||||
like a sign-in. The e-mail is the user's own address. Nothing is sent to it
|
||||
yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level
|
||||
DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate
|
||||
and are not changed here.
|
||||
|
||||
### Settings
|
||||
|
||||
`/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) — and how many days
|
||||
**Deliveries** rows are kept before the background sweep deletes them (7–365
|
||||
days; takes effect on the next six-hour prune cycle without a container
|
||||
restart). Application SASL logins are separate and are not changed here.
|
||||
**Server › Settings** (`/server/settings`, global role) holds what is true for
|
||||
the whole instance: how many days the **Outbound log** keeps its rows before the
|
||||
background sweep deletes them (7–365 days; takes effect on the next six-hour
|
||||
prune cycle without a container restart), and, read-only, the level-1 rate
|
||||
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
|
||||
There is no panel-wide DMARC report address: each domain has its own.
|
||||
|
||||
### Users
|
||||
|
||||
`/users` (global administrator only) creates, edits, and deletes panel users.
|
||||
There are two roles:
|
||||
**Server › Users** (`/server/users`, global role only) creates, edits, and
|
||||
deletes panel users. Everyone in the panel is an administrator, so the two
|
||||
roles name a *reach*, not a rank, and are called `global` and `domain` in the
|
||||
list, the role select and the page headers:
|
||||
|
||||
- **Global administrator** — full access to every page and every domain,
|
||||
including Users, Backup, Status, Mail queue, System log, Help, Inbound (when
|
||||
the inbound relay flag is on), and DMARC (when `DMARC_REPORTS_ENABLE=true`).
|
||||
- **Domain-admin** — scoped to one or more domains assigned by a global
|
||||
administrator. Sees only those domains' pages, applications, and
|
||||
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
|
||||
`/status`, `/mail-queue`, `/system-log`, `/inbound`, 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).
|
||||
- **global** — every page and every domain: Overview, Server (Health, System
|
||||
log, Backup, Users, Settings), the Outbound queue, and every outbound and
|
||||
inbound domain. The only role that adds or deletes a domain and that manages
|
||||
users.
|
||||
- **domain** — only the domains assigned to the user, outbound and inbound
|
||||
**separately**: the user form has one list for each, and the same name on
|
||||
both lists is two assignments. Each list has **All**, which includes domains
|
||||
added later; with it ticked the rows below are ignored. A *domain* user needs
|
||||
at least one domain, or *All*, on either list. A group with nothing assigned
|
||||
is absent from that user's menu.
|
||||
|
||||
The panel refuses to remove or demote the **last** global administrator, so
|
||||
it can never end up with none.
|
||||
What the *domain* role reaches inside what is assigned:
|
||||
|
||||
| | `global` | `domain` |
|
||||
|---|---|---|
|
||||
| Overview, Server, the Outbound queue | yes | no (404) |
|
||||
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
|
||||
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
|
||||
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
|
||||
| Add or delete a domain, either direction | yes | no |
|
||||
| Export a domain (Domain settings) | yes | assigned domains |
|
||||
| Import a domain (Server › Backup) | yes | no |
|
||||
| Account, Help | yes | yes |
|
||||
|
||||
A domain that is not assigned, one that does not exist, and a user with no
|
||||
reach in that direction all get the same 404.
|
||||
|
||||
The panel refuses to remove or demote the **last** global user, so it can never
|
||||
end up with none. A user cannot delete the account they are signed in with.
|
||||
The user form also sets the user's e-mail, which the users list shows.
|
||||
|
||||
### Sessions
|
||||
|
||||
A login survives a container restart: sessions live in SQLite, not in
|
||||
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
|
||||
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 **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.
|
||||
memory. A session belongs to the user it was created for, by id: deleting the
|
||||
user ends their sessions, and renaming them keeps theirs. Expiry is a sliding
|
||||
idle window (`PANEL_SESSION_IDLE_DAYS`, default seven days) with no absolute
|
||||
lifetime cap — a user who keeps using the panel stays signed in indefinitely.
|
||||
HTMX polling on the monitoring screens (Overview, Health, Outbound log and
|
||||
queue, System log) does **not** count as activity, so an auto-refreshing tab
|
||||
left open will not keep a session alive forever.
|
||||
|
||||
- Changing **your own** password on **Account** signs out every other session
|
||||
of yours but leaves the current browser signed in.
|
||||
- A password that a global user sets for **another** user on the user form
|
||||
ends all of that user's sessions; set for oneself, it behaves like the
|
||||
previous item.
|
||||
- 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
|
||||
|
||||
@@ -418,7 +502,8 @@ The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
|
||||
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
|
||||
catches a dead mail path that would still leave the HTTP server up, but it
|
||||
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
|
||||
use the authenticated [Status](#status) page for that. External monitoring
|
||||
use the authenticated [Overview and Health](#overview-and-health) pages for
|
||||
that. External monitoring
|
||||
can use the same endpoint through the reverse proxy if you expose it, or poll
|
||||
`docker inspect` health state on the host.
|
||||
|
||||
@@ -429,9 +514,10 @@ its mail hostname. Most receiving mail servers weigh this heavily; get it
|
||||
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 about one
|
||||
minute; use *Re-check* right after publishing a record.
|
||||
**Server › Health** verifies the server's hostname against this record
|
||||
(forward-confirmed reverse DNS), and Overview shows the verdict. Results are
|
||||
cached for about one minute; use *Re-check DNS* 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).
|
||||
@@ -468,9 +554,9 @@ deploy files — without that mount, *Full backup* refuses with an error.
|
||||
There are four ways to capture a full backup. They differ in whether the
|
||||
instance keeps running, what goes into the archive, and what happens on restore.
|
||||
|
||||
**1. Panel — while the container is running (no downtime).** *Backup* → *Full
|
||||
backup* (optionally tick *Encrypt with a password*). Same archive as the CLI
|
||||
below.
|
||||
**1. Panel — while the container is running (no downtime).** *Server › Backup*
|
||||
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
|
||||
archive. Same archive as the CLI below.
|
||||
|
||||
**2. `selfpost-backup` CLI — while the container is running (no downtime).**
|
||||
The panel button and this command call the same code path: a consistent SQLite
|
||||
@@ -552,8 +638,8 @@ blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM
|
||||
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
|
||||
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
|
||||
and both daemons are reloaded, healing any drift between the extracted files and
|
||||
the database. The Status page's *Reload configuration* button resyncs OpenDKIM,
|
||||
the sender map, and inbound maps only — not DMARC maps. This is why the
|
||||
the database. The *Reload configuration* button on Server › Health resyncs
|
||||
OpenDKIM, the sender map, and inbound maps only — not DMARC maps. 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)).
|
||||
@@ -619,34 +705,35 @@ the resulting `.tar.gz` — see [Encrypting a backup or
|
||||
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
||||
options.
|
||||
|
||||
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
|
||||
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
|
||||
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
|
||||
that already has `docker-compose.yml` and `.env`.
|
||||
**Archives and data directories from 1.x cannot be restored into 2.0.** The
|
||||
database is refused at start and left untouched — 2.0 starts from an empty data
|
||||
directory. Carry each domain over with its
|
||||
[export file](#exporting-and-importing-a-single-domain) instead.
|
||||
|
||||
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.
|
||||
current session; there is no "logout everywhere" — see
|
||||
[Sessions](#sessions) for what ends a user's sessions.
|
||||
|
||||
See also [Exporting and importing a single
|
||||
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
||||
operation that also lives on the *Backup* page (`/backup`).
|
||||
operation: *Export domain* is on the domain's settings page, *Import a domain*
|
||||
on *Server › Backup* (`/server/backup`).
|
||||
|
||||
Both a full backup and a domain export are **secrets** — they contain the
|
||||
admin password hash (full backup), TLS private keys and `.env` (full backup),
|
||||
or working application credentials (domain export) in the clear or in
|
||||
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
|
||||
around — and encrypt them, which SelfPost can do for you.
|
||||
password hashes of the panel's users (full backup), TLS private keys and `.env`
|
||||
(full backup), or working application credentials (domain export) in the clear
|
||||
or in directly reversible form. Treat them like any other credential material:
|
||||
restrict who can read them, don't email them around — and encrypt them, which
|
||||
SelfPost does unless you untick the box.
|
||||
|
||||
#### Encrypting a backup or export
|
||||
|
||||
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
|
||||
file that comes down is an encrypted envelope instead of the plain archive:
|
||||
Both download forms carry an **Encrypt with a password** checkbox, ticked by
|
||||
default. Ticked, the file that comes down is an encrypted envelope instead of
|
||||
the plain archive; unticked, it is the plain archive:
|
||||
|
||||
| Artefact | Plain | Encrypted |
|
||||
|----------|-------|-----------|
|
||||
@@ -686,27 +773,33 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||
### Inbound relay
|
||||
|
||||
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
|
||||
domains you list under *Inbound* and hands each message to the upstream host
|
||||
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
|
||||
stores the message locally.
|
||||
domains you list under *Inbound › Domains* and hands each message to the
|
||||
upstream host you configure. It is **not** mailboxes, IMAP, or webmail —
|
||||
SelfPost never stores the message locally.
|
||||
|
||||
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
|
||||
container. Until then there is no inbound-relay `smtp inet` listener, no
|
||||
*Inbound* item in the nav, and `/inbound` is 404. (Port 25 can still listen
|
||||
when only `DMARC_REPORTS_ENABLE=true` — see [DMARC reports](#dmarc-reports).)
|
||||
Outbound 465/587 is unchanged.
|
||||
*Inbound* group in the menu, and `/inbound/…` is 404. (Port 25 can still
|
||||
listen when only `DMARC_REPORTS_ENABLE=true` — see [DMARC
|
||||
reports](#dmarc-reports).) Outbound 465/587 is unchanged.
|
||||
|
||||
**Panel** (`/inbound`, global administrator only): add a domain, set the
|
||||
upstream host/port and TLS to that hop (opportunistic / required / off), and
|
||||
choose recipients — an allow-list, or any address at that domain. A domain
|
||||
with an empty upstream is kept in the database but is **not** published into
|
||||
Postfix maps, so mail is never accepted with nowhere to send it.
|
||||
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
|
||||
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
|
||||
required / off), and choose recipients — an allow-list, or any address at that
|
||||
domain. Inbound domains are assigned to a user with the *domain* role
|
||||
separately from outbound ones (see [Users](#users)); such a user sees and
|
||||
changes the upstream and recipients of the inbound domains assigned to them,
|
||||
and cannot add or delete one. The upstream may be any host and port, including
|
||||
loopback and private addresses — see the accepted risks in
|
||||
[security.md](security.md). A domain with an empty upstream is kept in the
|
||||
database but is **not** published into Postfix maps, so mail is never accepted
|
||||
with nowhere to send it.
|
||||
|
||||
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
|
||||
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
|
||||
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
|
||||
server — other MX targets (a primary mail server) are fine; this is how
|
||||
backup-MX is meant to work. Use *Re-check* after publishing.
|
||||
backup-MX is meant to work. Use *Re-check DNS* after publishing.
|
||||
|
||||
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
|
||||
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
|
||||
@@ -729,10 +822,23 @@ only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
|
||||
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
|
||||
defer instead.
|
||||
|
||||
**Inbound mail is not in Deliveries.** Port 25 runs its own milter chain: the
|
||||
optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
|
||||
The panel only reports this setting; it does not change it. Every inbound
|
||||
domain page has a **Spam filter** box — on or off, what happens to mail while
|
||||
the filter is down, and that the filter belongs to the server, not to the
|
||||
domain — and the domain list says in its head whether the filter is on. The
|
||||
filter's address (`INBOUND_ANTISPAM_MILTER`) is shown to the global role only,
|
||||
so a user with the *domain* role sees the state without the server's internals.
|
||||
For the global role the filter is also one of the milter sockets on **Server ›
|
||||
Health** and in the Overview card: the panel connects to the address (one
|
||||
second at most, nothing is sent) and, if there is no answer, says whether
|
||||
inbound mail is being deferred (`tempfail`) or going through unfiltered
|
||||
(`accept`).
|
||||
|
||||
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
|
||||
the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
|
||||
mail (and a signing outage therefore cannot defer it), and the journal-milter
|
||||
does not file it — [Deliveries](#deliveries) stays a record of what *you* sent.
|
||||
does not file it — the [Outbound log](#outbound-log) stays a record of what
|
||||
*you* sent.
|
||||
This also keeps a forged `From:` on inbound mail from counting against a
|
||||
sending domain's [level-2 rate limit](#rate-limiting--level-2-domain-and-application).
|
||||
|
||||
@@ -749,26 +855,42 @@ are not stored. This is separate from [Inbound relay](#inbound-relay) — no
|
||||
backup-MX, no forwarding upstream.
|
||||
|
||||
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
|
||||
container. Until then there is no report ingest, no *DMARC* item in the nav,
|
||||
and `/dmarc` is 404. Outbound 465/587 is unchanged.
|
||||
container. Until then there is no report ingest, no *DMARC reports* entry in
|
||||
the Outbound menu, and `/outbound/dmarc` is 404. Outbound 465/587 is unchanged.
|
||||
|
||||
**Addresses.** In *Settings* (global administrator), set the default
|
||||
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g.
|
||||
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted**
|
||||
(`dmarc-reports+<domain>@<hostname>`) under *Domain settings → DMARC reports*.
|
||||
Only those allow-listed addresses are accepted on port 25.
|
||||
**Addresses.** A domain's report address (`rua=`) is one of three choices, set
|
||||
in that domain's **Domain settings** (Outbound › Domains › the domain › Domain
|
||||
settings) and nowhere else:
|
||||
|
||||
- **No reports** — the state of a new domain; `rua=` is left out of the DMARC
|
||||
record.
|
||||
- **SelfPost hosted** — `dmarc-reports+<domain>@<hostname>`, an address on this
|
||||
server whose reports are parsed and shown under *DMARC reports*. Offered only
|
||||
while `DMARC_REPORTS_ENABLE=true`.
|
||||
- **A specific address** — any mailbox, typed for this domain; it receives the
|
||||
raw XML. Beside the field, *Use my e-mail* fills it with the e-mail of your
|
||||
profile (Account); it only fills the field, and saving is the form's own
|
||||
button. The button is absent when your profile has no e-mail.
|
||||
|
||||
Changing the choice changes the DMARC record to publish; the domain page shows
|
||||
the new value. There is no panel-wide or per-user default. Only addresses on
|
||||
`SELFPOST_HOSTNAME` are accepted on port 25: the generic
|
||||
`dmarc-reports@<hostname>` and any address on that host that a domain has
|
||||
chosen.
|
||||
|
||||
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
||||
`rua=mailto:…` pointing at your hosted address. Receivers deliver to the
|
||||
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
|
||||
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
|
||||
address domain if different) so reports reach this server. If a hub domain
|
||||
authorises external destinations, publish `_report._dmarc` there too; the panel
|
||||
checks it on the domain page.
|
||||
|
||||
**Panel.** *DMARC* (global administrator) lists recent reports and ingest
|
||||
health. Open a domain's roll-up from the list or from *View DMARC reports* on
|
||||
the domain page. Domain administrators see reports only for domains assigned to
|
||||
them. Summaries are pruned (500 kept, 90 days max).
|
||||
**Panel.** *Outbound › DMARC reports* lists recent reports. The global role also
|
||||
sees the *Ingest* box — whether ingest works, the last report received, reports
|
||||
kept this week and parse failures. Open a domain's roll-up from the list or
|
||||
from *DMARC reports* in the domain page's menu. A user with the *domain* role
|
||||
sees the reports of the domains assigned to them, without the *Ingest* box.
|
||||
Summaries are pruned (500 kept, 90 days max).
|
||||
|
||||
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
|
||||
alone, `check_recipient_access` permits only configured report addresses;
|
||||
@@ -780,7 +902,7 @@ predictable, so the report XML is treated as untrusted input: a report is
|
||||
accepted only when the domain it claims in `policy_published` is a sending
|
||||
domain configured here, and — when it arrived at a per-domain hosted address —
|
||||
only when that domain matches the address's `+tag`. Anything else is refused
|
||||
and counted in *parse failures* on the DMARC page, so a stranger cannot file
|
||||
and counted in *parse failures* on the DMARC reports page, so a stranger cannot file
|
||||
reports under your domain or push genuine ones out through the retention cap.
|
||||
Reports are accepted as gzip, zip, or plain XML.
|
||||
|
||||
@@ -789,17 +911,32 @@ Parsed report data lives in SQLite and is included in a
|
||||
|
||||
## Domain administration
|
||||
|
||||
### Domains page
|
||||
### Outbound domains
|
||||
|
||||
`/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), per-application limits, and
|
||||
optional client IP allow-lists 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 (**global administrator only**) — see [Exporting and importing a single
|
||||
domain](#exporting-and-importing-a-single-domain).
|
||||
**Outbound › Domains** (`/outbound/domains`) lists sending domains with the
|
||||
verdict of each DNS check, the DKIM selector, the number of applications and
|
||||
recent mail, and hosts the add-domain form (**global role only**). A user with
|
||||
the *domain* role sees only the domains assigned to them. Three pages belong
|
||||
to a domain:
|
||||
|
||||
- **The domain page** (`/outbound/domains/{id}`) — the *DNS records* to
|
||||
publish and what is in DNS now, the *Applications* (SASL logins) with *New
|
||||
password* and *Delete* in their rows, and the *Connection* settings. *Re-check
|
||||
DNS* and *Add application* are in its head; its menu leads to the domain's
|
||||
log and DMARC reports.
|
||||
- **The application form** (`…/applications/new`, `…/applications/{aid}`) —
|
||||
who the application may send as, its client IP allow-list and its rate limit
|
||||
are validated and saved together, or not at all, also when the application
|
||||
is created. A password is shown once, on the page that answers the creation
|
||||
or *New password*, and is never shown again.
|
||||
- **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC
|
||||
reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting —
|
||||
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain*
|
||||
and, for the global role, *Delete domain*, which asks once more.
|
||||
|
||||
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
|
||||
Backup* reads one back in (**global role only**) — see [Exporting and importing
|
||||
a single domain](#exporting-and-importing-a-single-domain).
|
||||
|
||||
### Domain-level DNS (SPF, DKIM, DMARC)
|
||||
|
||||
@@ -810,12 +947,14 @@ records; see [Inbound relay](#inbound-relay).
|
||||
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
||||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||
domain's page (*DNS records* box), one selector per domain.
|
||||
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||
only, safe to publish immediately). Set `rua=` to receive aggregate reports:
|
||||
with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
|
||||
from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
|
||||
mailbox elsewhere that receives inbound mail. If `rua=` points at another
|
||||
only, safe to publish immediately). Set `rua=` to receive aggregate reports
|
||||
by choosing the domain's report address in *Domain settings*: with [DMARC
|
||||
reports](#dmarc-reports) enabled, the SelfPost-hosted address; otherwise an
|
||||
address typed for the domain — a mailbox elsewhere that receives mail (*Use
|
||||
my e-mail* fills in your profile's) — or no reports, which leaves `rua=` out.
|
||||
The record the page shows follows that choice. If `rua=` points at another
|
||||
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||||
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||
report destinations.
|
||||
@@ -825,10 +964,10 @@ lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||
panel, add its DNS records at the same time**, not later.
|
||||
|
||||
Each domain's page shows a *DNS status* card comparing the published DKIM
|
||||
Each domain's page has a *DNS records* box comparing the published DKIM
|
||||
record against the key this server signs with, plus the domain's SPF,
|
||||
DMARC, and (when configured) DMARC report-authorisation records. Results are
|
||||
cached for a few minutes; use *Re-check* right after publishing a record.
|
||||
cached for a few minutes; use *Re-check DNS* right after publishing a record.
|
||||
The SPF check is deliberately shallow — it looks for a mechanism that
|
||||
literally covers this server's address and does not follow `include:` or
|
||||
`redirect=`, so a record that authorizes the server through an include is
|
||||
@@ -848,12 +987,13 @@ public internet, not something SelfPost's configuration can shortcut.
|
||||
|
||||
### Rate limiting — level 2 (domain and application)
|
||||
|
||||
Level 2 is optional, configured on each domain's page, and layers on top of
|
||||
Level 2 is optional, configured in each domain's settings (the domain) and
|
||||
on each application's form (the application), and layers on top of
|
||||
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
|
||||
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
|
||||
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
|
||||
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
|
||||
`rejected`.
|
||||
returns a 4xx and the refusal is recorded in the [Outbound log](#outbound-log)
|
||||
as `rejected`.
|
||||
|
||||
**Level 2 — domain** — a message ceiling and window for **every** client IP
|
||||
sending as that domain. When unset, only level 1 applies for non-privileged
|
||||
@@ -896,43 +1036,45 @@ accept rather than defer when it cannot reach it. Level 1 is the backstop
|
||||
that keeps working even when level 2 cannot run, and it is the only one of
|
||||
the two that does not depend on the panel process.
|
||||
|
||||
### Deliveries
|
||||
### Outbound log
|
||||
|
||||
`/deliveries` is a searchable send log with server-side filters by domain
|
||||
and application. A row identifies its message and nothing more — time,
|
||||
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
|
||||
filters by domain and application; a user with the *domain* role sees only the
|
||||
rows of their domains. It refreshes its rows without resetting a filter being
|
||||
chosen. A row identifies its message and nothing more — time,
|
||||
sender, recipient, subject and status `queued` (accepted, not yet
|
||||
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||||
retrying), `bounced` (final failure), or `rejected` (refused — for example
|
||||
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
||||
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
|
||||
*Details* opens that row's own page (`/outbound/log/{id}`). That page carries
|
||||
the sending domain, the application it was submitted under, the Postfix
|
||||
queue id and the journal id, beside the message's history — when it was
|
||||
accepted and what Postfix later reported for the recipient. A `deferred`
|
||||
or `bounced` row includes this Postfix's retry intervals (first delay,
|
||||
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
|
||||
administrators see them here because they cannot open Mail queue. Under
|
||||
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
|
||||
with the *domain* role see them here because they cannot open the queue. Under
|
||||
both sit the `mail.log` lines for its queue id: the connection to the
|
||||
receiving server, the server's reply, and the status that reply was filed
|
||||
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
||||
away; the page says so. Retention is set on **Settings** (global
|
||||
administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial
|
||||
default until it is changed there.
|
||||
away; the page says so. Retention is set under **Server › Settings** (global
|
||||
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
|
||||
is changed there.
|
||||
|
||||
### Exporting and importing a single domain
|
||||
|
||||
Domain page → *Export domain* to write the file, *Backup* → *Import a
|
||||
domain* to read it back in. This moves one domain — its DKIM key, its
|
||||
Domain settings → *Export domain* to write the file, *Server › Backup* →
|
||||
*Import a domain* to read it back in. This moves one domain — its DKIM key, its
|
||||
applications' **working** SASL passwords, and configured **rate limits**
|
||||
(mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
|
||||
instance without regenerating anything, so DNS (the DKIM TXT record)
|
||||
doesn't need to change. Unlike a full restore (see [Full backup and
|
||||
restore](#full-backup-and-restore)), this works across different
|
||||
hostnames/instances. *Import* is global-administrator only; *export* is
|
||||
available to any user who can access the domain, **including a domain-admin**
|
||||
for a domain assigned to them — so a domain-admin can walk away with that
|
||||
domain's working SASL passwords in the clear. Weigh that when deciding which
|
||||
domains to assign to a domain-admin account.
|
||||
hostnames/instances. *Import* is for the global role only; *export* is
|
||||
available to any user who can access the domain, **including a user with the
|
||||
*domain* role** for a domain assigned to them — so that user can walk away with
|
||||
the domain's working SASL passwords, in the clear if they untick the encryption
|
||||
box. Weigh that when deciding which domains to assign to a *domain* user.
|
||||
|
||||
A domain export is a secret in the same way a full backup is, and can be
|
||||
encrypted the same way — see [Encrypting a backup or
|
||||
export](#encrypting-a-backup-or-export).
|
||||
A domain export is a secret in the same way a full backup is. The *Encrypt with
|
||||
a password* box is ticked by default, and the export is sealed the same way —
|
||||
see [Encrypting a backup or export](#encrypting-a-backup-or-export).
|
||||
|
||||
@@ -531,7 +531,7 @@ only when both are done. Every stage-2 step ends with the evidence of
|
||||
|
||||
- [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
|
||||
- [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
|
||||
- [ ] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
|
||||
- [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
|
||||
- [ ] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku**
|
||||
|
||||
**After 2.0** — features land in the kit, each from its own plan, mockup first
|
||||
|
||||
+4
-4
@@ -59,7 +59,7 @@ Explicitly excluded to prevent scope creep:
|
||||
|
||||
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
||||
- Webmail
|
||||
- Organisations / tenancy beyond global + domain-admin roles; managing
|
||||
- Organisations / tenancy beyond the `global` and `domain` roles; managing
|
||||
**multiple sending domains** under one global administrator is in scope (see
|
||||
below)
|
||||
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
|
||||
@@ -67,9 +67,9 @@ Explicitly excluded to prevent scope creep:
|
||||
- A custom MTA — Postfix is used as-is
|
||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||
|
||||
The **domain-admin** role ships in the current line (global administrator plus
|
||||
domain administrators with assigned domains). The optional **inbound relay**
|
||||
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
|
||||
The **`domain`** role ships in the current line (a `global` user plus
|
||||
`domain` users with assigned domains, outbound and inbound separately). The
|
||||
optional **inbound relay** (backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
|
||||
`INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
|
||||
retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
|
||||
**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
|
||||
|
||||
+6
-4
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| panel-notifications | E-mail notifications about important events | candidate | — | — |
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 24/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 25/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
|
||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||
@@ -277,8 +277,8 @@ is in the table below; the rest of it shipped in
|
||||
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
|
||||
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
|
||||
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
|
||||
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout |
|
||||
| UI | Two `back_link` elements in a row in `dmarc_domain.html` |
|
||||
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
|
||||
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
|
||||
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
|
||||
|
||||
**Boundary:** no feature work and no behaviour change an operator would notice,
|
||||
@@ -397,7 +397,9 @@ requests are rate-limited, and it passes a security review.
|
||||
|
||||
**Dependencies / risks:** the account e-mail; an account-takeover surface where
|
||||
there was none — security review is part of the item, not an afterthought.
|
||||
Not drawn yet.
|
||||
Not drawn yet. Changing the account e-mail must then require the current
|
||||
password (2.0 security review), because the e-mail becomes where a reset link
|
||||
goes and today Account saves it without the password.
|
||||
|
||||
**Version:** `2.x` MINOR; `candidate`.
|
||||
|
||||
|
||||
+123
-21
@@ -16,7 +16,17 @@ whole diff from 1.3.0 (`7052395`) to HEAD past 1.9.1 — the inbound-relay path
|
||||
limiting, application auth-IP allow-lists, and the new panel surface
|
||||
(handlers, templates, settings): no exploitable findings; two deliberate
|
||||
behaviours the pass examined are now recorded under Accepted risks
|
||||
(unauthenticated DMARC ingest, fail-open auth-IP enforcement).
|
||||
(unauthenticated DMARC ingest, fail-open auth-IP enforcement). The 2.0 review
|
||||
(Fable, 2026-10-10; fixed and re-checked by other models) covered the route
|
||||
rename, the `/server/` subtree guard, inbound delegation and the new pages: no
|
||||
Critical or High finding; sessions were keyed by user name (now by user id),
|
||||
a password change ended other users' sessions, a password set for a user on
|
||||
the user form did not end that user's sessions, internal errors reached forms a
|
||||
`domain` user can open, and `/static/` listed its files — all fixed
|
||||
(see [Authentication and sessions](#authentication-and-sessions) and [Errors
|
||||
shown to users](#errors-shown-to-users)); two deliberate behaviours are
|
||||
recorded under Accepted risks (upstream of a delegated inbound domain, profile
|
||||
saved without the password).
|
||||
(2) **Accepted risks** —
|
||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||
lost.
|
||||
@@ -74,6 +84,72 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
`SameSite`.
|
||||
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||
- **A session belongs to a user by id** (`sessions.user_id`, deleted with the
|
||||
user). The user is loaded on every request and a session is renewed only
|
||||
after it was found, so the cookie of a deleted user is dead and cannot become
|
||||
the session of whoever is created under the same name later; renaming a user
|
||||
leaves their sessions alone.
|
||||
- **What ends a user's sessions:** deleting the user (all); a password change on
|
||||
Account (that user's *other* sessions — never another user's); a password set
|
||||
for them by a global user on the user form (all of theirs, except the current
|
||||
one when they set their own); signing out (the current one only). There is no
|
||||
"sign out everywhere", and a restored backup brings back the session rows it
|
||||
held.
|
||||
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
|
||||
session.
|
||||
|
||||
### Roles
|
||||
|
||||
Everyone who signs in to the panel is an administrator, so the two roles name a
|
||||
*reach*, never a rank: `global` and `domain` (in the users list, the role
|
||||
select, the page headers and these documents — not "admin"). A `domain` user is
|
||||
assigned outbound domains and inbound domains **separately**, each list with
|
||||
*All* (which includes domains added later and widens the list, not the role).
|
||||
|
||||
| | `global` | `domain` |
|
||||
|---|---|---|
|
||||
| Overview, Server (Health, System log, Backup, Users, Settings, kit page) | yes | no — 404 |
|
||||
| Outbound queue | yes | no — 404 (a queue is not filtered by domain) |
|
||||
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
|
||||
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
|
||||
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
|
||||
| Add or delete a domain, either direction; import a domain | yes | no |
|
||||
| Account, Help | yes | yes |
|
||||
|
||||
- A path a user may not reach, a domain or application that does not exist,
|
||||
and one that belongs to another tenant answer the **same 404**, so the panel
|
||||
does not confirm what exists. A group with nothing assigned is absent from
|
||||
the user's menu.
|
||||
- `/server/` is guarded once, by `globalOnly` where its routes are registered
|
||||
(a route outside the subtree cannot be added through that helper), and every
|
||||
handler checks again. Outbound and inbound handlers check per object
|
||||
(`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment
|
||||
in the query. Each denied path has a test for a `domain` user, including
|
||||
another tenant's id.
|
||||
- The panel will not remove or demote the last `global` user.
|
||||
- A `domain` user can export an assigned domain, and an export carries the
|
||||
working application passwords (encrypted unless the box is unticked): assign
|
||||
domains with that in mind.
|
||||
|
||||
### State-changing requests
|
||||
|
||||
- Every state change is a `POST`; a `GET` never changes state (the delete
|
||||
confirmation pages only render a form).
|
||||
- Every non-read request first passes the origin check
|
||||
(`originAllowed`, [internal/web/security.go](../internal/web/security.go)):
|
||||
`Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own
|
||||
host; the session cookie is `SameSite` as well.
|
||||
- There are **no CSRF tokens yet**. The decision, its price and the conditions
|
||||
for revisiting it are in the accepted risks and the ADR below.
|
||||
|
||||
### Errors shown to users
|
||||
|
||||
A page shows a validation message (what to fix) and nothing else of an error. A
|
||||
database, `saslpasswd2` or Postfix-reload failure is logged and the page says
|
||||
only that the change could not be saved and to check the logs. This holds on
|
||||
every form a `domain` user can open. The global-only Backup page (full backup,
|
||||
domain import) still shows the underlying message of an import failure, so the
|
||||
operator can act on it.
|
||||
|
||||
### Output and process
|
||||
|
||||
@@ -103,11 +179,12 @@ The panel is exposed to the internet — the items below are **not optional**.
|
||||
### Backup and domain export
|
||||
|
||||
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
|
||||
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
|
||||
panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS
|
||||
private key from `certs/` when present; a domain export carries the DKIM key
|
||||
and **working** application passwords in the clear (otherwise a transfer
|
||||
without recreating credentials would be impossible).
|
||||
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||
- Both downloads are encrypted with a password unless the box on the form is
|
||||
unticked (it is ticked by default):
|
||||
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||
a truncated or substituted file fails to open instead of silently restoring a
|
||||
@@ -134,7 +211,7 @@ deferred item from the roadmap.
|
||||
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||
A client that sends neither — a genuinely old browser, or a webview with a
|
||||
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||
deliberately: every panel user (global or domain-admin) is an operator who
|
||||
deliberately: every panel user (role `global` or `domain`) is an operator who
|
||||
picks their own browser, not an untrusted party the panel needs to defend
|
||||
against, and a strict mode would not "protect" such a client, it would
|
||||
simply break the panel in it. Tightening is one line in `originAllowed`
|
||||
@@ -144,7 +221,7 @@ deferred item from the roadmap.
|
||||
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||
revisit is a requirement for protection that holds regardless of the browser,
|
||||
or a domain-admin population the global administrator does not fully trust
|
||||
or a population of `domain` users the global user does not fully trust
|
||||
(see the ADR below). A token would not save the panel from XSS inside it
|
||||
either: code executing in the panel's origin sends the request itself —
|
||||
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||
@@ -162,20 +239,24 @@ deferred item from the roadmap.
|
||||
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||
enhancement means the panel must work with JavaScript off; a
|
||||
server-rendered confirmation step would need a second page (or a `?confirm=1`
|
||||
round trip) for every one of these forms, which is what
|
||||
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||
- **Encrypting backups and exports is an option, not the default.** With the
|
||||
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||
operator with nowhere to keep a password would lose the ability to take a
|
||||
backup at all, and a permanently undecryptable archive is worse than an
|
||||
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||
encryption mandatory is a second administrator (at which point "who
|
||||
downloaded it" stops being one person).
|
||||
round trip) for every one of these forms, which is what the delete pages
|
||||
([`user_delete.html`](../internal/web/view/templates/user_delete.html),
|
||||
[`out_domain_delete.html`](../internal/web/view/templates/out_domain_delete.html),
|
||||
[`in_domain_delete.html`](../internal/web/view/templates/in_domain_delete.html))
|
||||
already do for the highest-blast-radius deletes.
|
||||
- **Encrypting backups and exports can be switched off.** The panel's
|
||||
*Encrypt with a password* box is ticked by default, but with it cleared the
|
||||
file downloads in the clear, and the `selfpost-backup` CLI writes a plain
|
||||
archive unless it is given a password. Otherwise an operator with nowhere to
|
||||
keep a password would lose the ability to take a backup at all, and a
|
||||
permanently undecryptable archive is worse than an unencrypted one:
|
||||
SelfPost does not store the password. The trigger to make encryption
|
||||
mandatory is a second administrator (at which point "who downloaded it"
|
||||
stops being one person).
|
||||
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||
read position (`logtail_state`), so the tail is read after a
|
||||
start. What remains are rows whose delivery lines are lost for good (the log
|
||||
rotated past 14 files while the panel was down, or was deleted): the
|
||||
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||
@@ -223,14 +304,35 @@ deferred item from the roadmap.
|
||||
client IPs, but neither message bodies nor headers; it is excluded from
|
||||
backups (`log/` is skipped) so that a dump stays state rather than
|
||||
diagnostics.
|
||||
- **A `domain` user may point an inbound domain's upstream at any host and
|
||||
port.** The upstream form checks that the host is a well-formed name or IP
|
||||
and the port is 1–65535; loopback, private and link-local addresses are
|
||||
allowed, so a `domain` user with an assigned inbound domain can make this
|
||||
server's Postfix open SMTP connections to any address it can reach and hand
|
||||
mail for that domain to it. Accepted deliberately: delegation is within the trusted-operator model — a `domain`
|
||||
user is an operator the `global` user chose to give that domain, and the
|
||||
upstream of a backup-MX is by nature a host on someone's private network.
|
||||
The connection is made by Postfix and carries only mail for that domain; the
|
||||
panel itself makes no request to the upstream. Trigger to revisit: `domain`
|
||||
users who are not trusted operators — the same condition as for CSRF tokens
|
||||
— at which point an allow-list of upstream networks, or restricting the
|
||||
field to the global role, is the fix.
|
||||
- **Account's profile (username, e-mail) is saved without the password.**
|
||||
Saving the profile does not ask for the current password, as changing the
|
||||
password does. Today the e-mail is only the address the *Use my e-mail*
|
||||
button fills in, so a stolen session gains nothing it did not have. It
|
||||
becomes a requirement to ask for the current password when changing
|
||||
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
|
||||
link to it — from then on whoever can change the address could take the
|
||||
account over.
|
||||
|
||||
## ADR: CSRF via origin checking, without tokens
|
||||
|
||||
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||
page apart from one initiated by a third-party site in a logged-in user's
|
||||
browser. The panel is multi-user since 1.2.0 (a global administrator plus
|
||||
zero or more domain-admin users, each scoped to their assigned domains), but
|
||||
browser. The panel is multi-user since 1.2.0 (a `global` user plus
|
||||
zero or more `domain` users, each scoped to their assigned domains), but
|
||||
that is an authorization boundary (who can see or change what), not a change
|
||||
to the CSRF threat: the attacker in scope here is still an external site
|
||||
riding a legitimate user's cookie, not one panel user attacking another
|
||||
@@ -244,7 +346,7 @@ There are no session-bound tokens embedded in forms. The check applies the same
|
||||
way regardless of the requesting user's role.
|
||||
|
||||
**Why not tokens.** Cross-user CSRF is not the threat model here: a
|
||||
domain-admin's browser sending a request still needs that domain-admin's own
|
||||
`domain` user's browser sending a request still needs that user'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
|
||||
@@ -264,8 +366,8 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
|
||||
such a client, at the price of a narrow residual surface.
|
||||
|
||||
**Revisit if:** a requirement appears for protection that does not depend on
|
||||
browser behaviour, or domain-admin accounts stop being trusted operators (for
|
||||
example, if a future release lets a global administrator invite domain-admins
|
||||
browser behaviour, or `domain` accounts stop being trusted operators (for
|
||||
example, if a future release lets a `global` user invite `domain` users
|
||||
whose browsers/devices are not vetted) — at that point cross-role request
|
||||
forgery inside the panel would need its own analysis, separate from the
|
||||
external-site case this ADR covers.
|
||||
|
||||
Reference in New Issue
Block a user