Compare commits

17 Commits

Author SHA1 Message Date
mix 2f888dc7be ci: drop per-arch GHCR tags via GitHub Packages API
test / test (push) Has been cancelled
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-15 22:57:34 +03:00
mix 5598b62e4e release: 1.3.0
test / test (push) Has been cancelled
Close Unreleased for security, tests, docs, and release CI; pin compose and docs to 1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:51:29 +03:00
mix 32f5085c48 docs: record release CI workflow changes in [Unreleased]
The three post-1.2.5 CI commits that tie image publish to a published GitHub
Release and trim per-arch GHCR tags were missing from the changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:47:09 +03:00
mix 5601f73622 ci: consolidate release workflow and trim GHCR tags
test / test (push) Has been cancelled
Run release.yml directly on release:published instead of a dispatcher
workflow. Remove per-arch tags from GHCR after the manifest merge so only
X.Y.Z remains visible to operators.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:44:33 +03:00
mix e17b1680bd ci: dispatch release build from release-on-publish workflow
test / test (push) Has been cancelled
GitHub recorded release published at 16:44:21Z but no release.yml run
started. A thin release-on-publish workflow listens for publish and starts
release.yml via workflow_dispatch (always creates a run). release.yml now
checks out vX.Y.Z from the version input, not main HEAD.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:48:20 +03:00
mix 5b63da0e49 ci: trigger release workflow on published GitHub Release
test / test (push) Has been cancelled
Bare git tag pushes no longer start the image build; publishing a GitHub
Release does. workflow_dispatch still requires an explicit X.Y.Z version.
Docs explain draft vs published releases and that deleting a tag on GitHub
converts a published release back to draft.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:43:06 +03:00
mix e9aaed1c7b docs: reconcile [Unreleased] with full git history since 1.2.5
test / test (push) Has been cancelled
Expand the doc-alignment entry to cover guide RBAC and restore Resync
wording; trim the P2 entry of claims superseded by that pass while keeping
its /license route-table addition with a cross-reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:40:46 +03:00
mix 1bd71c10d0 docs: align operator docs with routes, sessions, and restore
test / test (push) Has been cancelled
Fix architecture route RBAC, restore Resync, and session/password wording;
correct guide restore/session/PTR claims and README port-587 text; point
the CSRF ADR at authz.go for global route gating.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:36:24 +03:00
mix 74acf09212 docs: verify [Unreleased] against git since 1.2.5
test / test (push) Has been cancelled
Move restore Resync under Fixed to match e70ba90, and extend the restore
test bullet with the resync boot step and TestResyncAfterRestoreHealsDriftedMaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:30:12 +03:00
mix ba07decbb0 docs: bring [Unreleased] up to date with guide and review closure
Record the operator-guide restructure, merge the stale code-review plan
entry with its completion note, and align the restore Resync bullet with
the current backup package comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:24:29 +03:00
mix 4fc5cff266 docs(guide): add worked restore commands to Full backup and restore
test / test (push) Has been cancelled
The section explained what a restore means conceptually but gave no
runnable commands. Add a step-by-step in-place restore, a
move-to-a-new-host variant, and the encrypted-backup decrypt-first
sequence, plus the actual version-mismatch error text so operators
know what to expect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:18:54 +03:00
mix ac8c2af500 docs(guide,architecture): move internal env vars out of the operator guide
test / test (push) Has been cancelled
Internal (non-operator) env vars were listed in guide.md's Environment
variables section even though the guide's own text says operators
aren't meant to touch them. They now live in architecture.md §
Configuration, the as-built/internals doc; guide.md keeps a one-line
pointer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:13:33 +03:00
mix 9e3f0d0fad docs(guide): reorder Installation and add a Full deployment walkthrough
test / test (push) Has been cancelled
Installation now reads Ports, Local trial, Initial setup, Full
deployment (with Fixed image tag nested under it), Environment
variables, Reverse proxy. The step-by-step production deploy and the
per-proxy TLS commands move from README's "Reference deploy" into the
operator guide; README keeps a short pointer instead of duplicating
the walkthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 15:49:58 +03:00
mix 403c76d17d docs(guide): restructure into Installation / Instance admin / Domain admin
test / test (push) Has been cancelled
Splits mixed DNS-setup, Operations, Rate-limiting, and Backup sections
along the instance/domain boundary and fixes README anchors that
pointed at the old #dns-setup heading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 15:45:38 +03:00
mix d9ab7bfa45 docs: retire the completed full-tree review plan
test / test (push) Has been cancelled
Every phase (P0-P7) is closed, so the plan file goes per its own exit criteria; history lives in git and the CHANGELOG. The roadmap's recommended order returns to queue-retries and then inbound-relay, and the order notes in both plans stop pointing at the deleted file, as does the 1.2.5 changelog entry (same treatment as logrotate-mode.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:23:52 +03:00
mix 18bfd148ec docs: close P7 — security review of the P0–P1 diff, no findings
The Fable review of the send-log authorization, tryAdmit, session create and app-delete ordering against security.md found no further issues: every send-log query path states its scope and an empty scope matches nothing, rejected rows cannot consume rate-limit budget, the milter's nil-registry fail-open is test-only, and each failure residue of the reordered delete fails safe. Outcome recorded in security.md's header and the plan; nothing added to the accepted risks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:02:07 +03:00
mix e70ba9046e fix(panel): resync mail-path maps once after restore
When CheckRestore accepts a backup manifest, the panel re-derives OpenDKIM tables and the Postfix sender map from SQLite on that first boot and reloads both daemons, so archive/database drift is healed before mail flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 14:58:08 +03:00
20 changed files with 1003 additions and 1224 deletions
+48 -18
View File
@@ -1,13 +1,9 @@
name: release name: release
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
# Ordinary commits do not publish anything. A pushed tag matching vX.Y.Z does, # Ordinary commits and bare git tag pushes do not publish anything. A published
# and so does workflow_dispatch when it supplies a SemVer X.Y.Z version (or # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# runs on such a tag). A dispatch from main without that input must fail in # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# prepare — it must not publish ghcr.io/...:main. The version is the single
# source that goes into both the image tag and the panel binary's -ldflags
# version, so the two can never drift apart (the invariant restore's version
# check in spec 7.5.A depends on).
# #
# Native per-architecture builds (see docs/development.md), not qemu: # Native per-architecture builds (see docs/development.md), not qemu:
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate # running the full Postfix/OpenDKIM stack under emulation for the e2e gate
@@ -15,18 +11,17 @@ name: release
# tag on its own native runner; a merge job then combines them into the one # tag on its own native runner; a merge job then combines them into the one
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not # manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
# push-by-digest then test) is deliberate: it means the bytes that get tagged # push-by-digest then test) is deliberate: it means the bytes that get tagged
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering # are exactly the bytes that passed e2e. Per-arch tags are pushed only so
# in the registry as a side effect (harmless — the version tag's immutability, # imagetools can assemble the multi-arch manifest; merge removes them from GHCR
# spec 10.1, is about that tag, not these). # so operators see a single version tag (spec 10.1).
on: on:
push: release:
tags: types: [published]
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Image version as X.Y.Z (no v prefix). Required unless this run is already on a vX.Y.Z tag." description: "Image version as X.Y.Z (no v prefix)."
required: false required: true
type: string type: string
permissions: permissions:
@@ -43,15 +38,18 @@ jobs:
id: version id: version
env: env:
INPUT_VERSION: ${{ github.event.inputs.version }} INPUT_VERSION: ${{ github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
EVENT_NAME: ${{ github.event_name }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ "$EVENT_NAME" = "release" ]; then
raw="${RELEASE_TAG:-}"
else
raw="${INPUT_VERSION:-}" raw="${INPUT_VERSION:-}"
if [ -z "$raw" ] && [[ "${GITHUB_REF_NAME}" == v[0-9]*.[0-9]*.[0-9]* ]]; then
raw="${GITHUB_REF_NAME#v}"
fi fi
raw="${raw#v}" raw="${raw#v}"
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::release version is not SemVer X.Y.Z (ref is ${GITHUB_REF_NAME}; pass inputs.version on workflow_dispatch). Refusing to publish ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}" echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
exit 1 exit 1
fi fi
echo "version=${raw}" >> "$GITHUB_OUTPUT" echo "version=${raw}" >> "$GITHUB_OUTPUT"
@@ -69,6 +67,8 @@ jobs:
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
@@ -133,6 +133,10 @@ jobs:
needs: [prepare, build] needs: [prepare, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io - name: Log in to ghcr.io
@@ -172,3 +176,29 @@ jobs:
attempt=$((attempt + 1)) attempt=$((attempt + 1))
backoff=$((backoff * 2)) backoff=$((backoff * 2))
done done
- name: Remove per-arch tags from GHCR
# Side-effect tags for imagetools assembly only — not part of the public
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="${{ needs.prepare.outputs.version }}"
owner="${{ github.repository_owner }}"
pkg="${{ github.event.repository.name }}"
api="/users/${owner}/packages/container/${pkg}/versions"
for suffix in amd64 arm64; do
tag="${version}-${suffix}"
mapfile -t ids < <(gh api "$api" --paginate \
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
if [ "${#ids[@]}" -eq 0 ]; then
echo "no GHCR package version for tag ${tag}"
continue
fi
for id in "${ids[@]}"; do
echo "deleting GHCR package version ${id} (tag ${tag})"
gh api -X DELETE "${api}/${id}"
done
done
+98 -29
View File
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.3.0] - 2026-08-14
Security and quality after 1.2.5: domain-admin send-log authorization,
fail-closed sign-in and application delete, level-2 rate-limit race fix,
restore Resync, expanded tests, operator docs, release CI, and OFL for IBM
Plex. Upgrading from 1.2.x is a tag bump; no migration.
### Added ### Added
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
@@ -19,6 +26,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
Explanation only; no attempt counter and no panel knobs. Not yet Explanation only; no attempt counter and no panel knobs. Not yet
implemented. implemented.
### Security
- The independent security review of the send-log authorization and
fail-closed fixes below (code-review plan § P7; reviewer model ≠ author
model) found no further issues: the domain scope holds on every query path,
a rate-limit refusal cannot consume window budget, and each failure residue
of the reordered application delete fails safe. Nothing was added to
[docs/security.md](docs/security.md) § Accepted risks; the review is
recorded in that file's header.
### Fixed ### Fixed
- test (e2e): send-log status scrapers follow the badge markup in - test (e2e): send-log status scrapers follow the badge markup in
@@ -64,18 +81,78 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
through a confirmation page, the same pattern as domain deletion, instead of through a confirmation page, the same pattern as domain deletion, instead of
a plain submit button next to Save with no confirmation at all. a plain submit button next to Save with no confirmation at all.
- panel (restore): after a backup is extracted and the version guard passes,
the panel runs one mail-path Resync on the first boot — OpenDKIM's tables
and Postfix's sender map are re-derived from SQLite and the daemons are
reloaded, so drift between the archive and the database is healed before
mail flows. Later starts skip that step; the Status page Reload button runs
the same Resync on demand. The `internal/backup` package comment now matches
this behaviour.
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The merge job had called
`docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed
with a warning and the side-effect tags stayed in the registry.
### Changed ### Changed
- docs: operator and as-built docs aligned with the code after a full
pass — [architecture.md](docs/architecture.md) route table now marks
**global** routes (404 for domain administrators) and documents the
one-time restore Resync in Persistence; session/password and restore-session
wording corrected in [guide.md](docs/guide.md) and architecture (own-password
change vs admin reset, no "logout everywhere", immediate session restore on
the next request, PTR cache ≈1 min, decrypt has no version check, restore
Resync on first boot, domain add/delete/import and `POST /reload` global-only,
Settings DMARC global-only); [README.md](README.md) port-587 and quick-start
volume wording fixed; [security.md](docs/security.md) CSRF ADR points at
`authz.go` for route gating. No behaviour change.
- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
administration**, and **Domain administration** — DNS setup, operations,
rate limiting, and backup sections follow the instance/domain boundary
instead of mixing them. **Installation** now reads Ports → Local trial →
Initial setup → Full deployment (with the fixed image tag nested under it) →
Environment variables → Reverse proxy; the step-by-step production deploy and
per-proxy TLS commands move here from README's "Reference deploy" (README
keeps a short pointer). Internal (non-operator) environment variables move
to [architecture.md](docs/architecture.md) § Configuration; the guide keeps
a one-line pointer. **Full backup and restore** gains worked commands for
in-place restore, move-to-a-new-host, and encrypted-backup decrypt-first,
plus the version-mismatch error text. README anchors updated for the new
headings. No behaviour change.
- docs: the 2026-08-13 full-tree review plan is complete — every phase (P0P7)
is closed — and `docs/plans/code-review.md` is deleted per its own exit
criteria (history in git and in this file). The plan covered architecture,
quality, GUI, tests, and licence work; P0 was domain-admin send-log
authorization. The [roadmap](docs/roadmap.md)'s recommended order returns to
**queue-retries** and then **inbound-relay**; it still records
**schema-squash** (replace the 1.x SQLite migration chain with a 2.x baseline;
not a reason to cut a major on its own).
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
`internal/legal/legal.go` (the value the panel footer actually injects), not `internal/legal/legal.go` (the value the panel footer actually injects), not
`layout.html`. `release.yml` refuses a `workflow_dispatch` whose version is `layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
not SemVer `X.Y.Z`, so a run from `main` cannot publish `ghcr.io/...:main`. packages were dropped so the tree is consistent; AGPL-3.0 does not require
Per-file `SPDX-License-Identifier` headers on the two command packages were them ([development.md](docs/development.md) § External libraries). Deleted the
dropped so the tree is consistent; AGPL-3.0 does not require them
([development.md](docs/development.md) § External libraries). Deleted the
completed `docs/plans/logrotate-mode.md` (history in git and completed `docs/plans/logrotate-mode.md` (history in git and
[1.2.3](#123---2026-08-12)). [1.2.3](#123---2026-08-12)).
- ci: the release image is published only for a **published** GitHub Release
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
bare git tag push no longer starts the build. `release.yml` listens for
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest,
then removes the per-arch tags from GHCR via the GitHub Packages API so
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what
`deploy/docker-compose.yml` pins). A dispatch whose version input is missing
or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md)
documents draft vs published releases, why deleting a release tag converts
it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release
tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags`
workflow).
- test: the authorization and sign-in surfaces that had no tests now have them. - test: the authorization and sign-in surfaces that had no tests now have them.
The login limiter is covered for its ceiling, its per-address scope, the reset The login limiter is covered for its ceiling, its per-address scope, the reset
at the end of a window and the sweep that keeps finished buckets out of at the end of a window and the sweep that keeps finished buckets out of
@@ -92,13 +169,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
downloaded from a running panel through `POST /backup` (plain and encrypted), downloaded from a running panel through `POST /backup` (plain and encrypted),
unpacked the way `tar -xzf` unpacks it onto the `/data` bind mount, and a unpacked the way `tar -xzf` unpacks it onto the `/data` bind mount, and a
second panel is booted on the result through the startup order the panel second panel is booted on the result through the startup order the panel
itself uses — version guard, database, services, HTTP application. The itself uses — version guard, database, one Resync when restoring, then
restored panel shows the domain and journal the archive carried, finds the services and the HTTP application. The restored panel shows the domain and
DKIM key, SASL database and Postfix sender map where its configuration says journal the archive carried, finds the DKIM key, SASL database and Postfix
they are, does not reopen the one-time setup link, and still honours a sender map where its configuration says they are, does not reopen the
session that predates the backup. A data directory left by another version is one-time setup link, and still honours a session that predates the backup.
refused with both versions named and the manifest kept. `serveHTTP` is split Drifted on-disk maps are healed by that Resync step
in two so that composition can be started without binding a port; no (`TestResyncAfterRestoreHealsDriftedMaps`). A data directory left by another
version is refused with both versions named and the manifest kept. `serveHTTP`
is split in two so that composition can be started without binding a port; no
behaviour change. behaviour change.
- test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so - test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so
@@ -128,12 +207,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
prompts existed — and why that is acceptable (the prompt is a mis-click prompts existed — and why that is acceptable (the prompt is a mis-click
guard, not an authorization boundary). guard, not an authorization boundary).
- docs: full-tree review plan ([docs/plans/code-review.md](docs/plans/code-review.md))
— architecture, quality, GUI, tests, licence; P0 is domain-admin send-log
authorization. Roadmap queues that plan ahead of inbound-relay and records
`schema-squash` (replace the 1.x SQLite migration chain with a 2.x baseline;
not a reason to cut a major on its own).
- docs: security and operator docs updated for the panel that has shipped - docs: security and operator docs updated for the panel that has shipped
global administrators and domain-admins since 1.2.0. The CSRF ADR in global administrators and domain-admins since 1.2.0. The CSRF ADR in
[security.md](docs/security.md) no longer argues from "single-user"; it now [security.md](docs/security.md) no longer argues from "single-user"; it now
@@ -141,15 +214,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
check defends against, and gives a new revisit trigger. Dropped the check defends against, and gives a new revisit trigger. Dropped the
unimplemented "or argon2" alternative for the password hash. unimplemented "or argon2" alternative for the password hash.
[guide.md](docs/guide.md) documents the Users page and the two roles, [guide.md](docs/guide.md) documents the Users page and the two roles,
the Settings page's default DMARC report address, level-2 rate limiting's level-2 rate limiting's fail-open behaviour, and that a domain-admin can
fail-open behaviour, that restoring an older backup can resurrect sessions, export working SASL passwords for domains assigned to them.
and that a domain-admin can export working SASL passwords for domains [architecture.md](docs/architecture.md) gains `/license` and the
assigned to them. [architecture.md](docs/architecture.md)'s route table now `/account``/settings` redirect in the route table (later expanded for
lists `/license` and the `/account``/settings` redirect. Fixed the RBAC in the doc-alignment pass above). Corrected stale
`internal/backup` package comment, which claimed the panel regenerates `admin.dmarc_report_email` references in
Postfix/OpenDKIM state from SQLite on every restore boot (it only runs the
version guard; drifted daemon state is healed by the Status page's Reload
button). Corrected stale `admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and [roadmap.md](docs/roadmap.md) and
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's [docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
actual home after migration `0005`. No behaviour change. actual home after migration `0005`. No behaviour change.
@@ -162,8 +232,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
expired buckets on a timer and cap the map at 4096 keys; the five expired buckets on a timer and cap the map at 4096 keys; the five
show/hide field helpers in `panel.js` are one rule table; DMARC copy no show/hide field helpers in `panel.js` are one rule table; DMARC copy no
longer promises in-panel report reception in a future release — SelfPost longer promises in-panel report reception in a future release — SelfPost
does not receive inbound mail. No optional post-restore map `Resync` (the does not receive inbound mail.
backup package comment fix in P2 is enough).
## [1.2.5] - 2026-08-13 ## [1.2.5] - 2026-08-13
+15 -133
View File
@@ -26,7 +26,7 @@ send log and DNS checks in the panel, encrypted backups.
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Full-server backup and single-domain export/import (optional password encryption) - Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; data in a `./data` bind mount - Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
## Documentation ## Documentation
@@ -76,14 +76,15 @@ For every domain you add in the panel:
- [ ] DKIM TXT record (value shown on the domain page) - [ ] DKIM TXT record (value shown on the domain page)
- [ ] DMARC `_dmarc` TXT record - [ ] DMARC `_dmarc` TXT record
See [DNS setup](docs/guide.md#dns-setup) in the operator guide. See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
## Quick start ## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints > **First boot — create the admin account.** On a fresh container SelfPost prints
> a **one-time setup URL** (valid ten minutes). Open it in a browser to choose > a **one-time setup URL** (valid ten minutes). Open it in a browser to choose
> the administrator username and password. Until you do, the panel has no login. > the administrator username and password. Until you do, the panel has no login.
> Production deploy: [step 3](#3-start-selfpost). > Production deploy: [Full deployment](docs/guide.md#full-deployment) in the
> operator guide.
One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
files, no compose files. Good for clicking through the UI on your machine; files, no compose files. Good for clicking through the UI on your machine;
@@ -95,7 +96,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.2.5 ghcr.io/mixeme/selfpost:1.3.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
@@ -129,136 +130,17 @@ TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
only through a reverse proxy on 443 — port 8080 is bound to localhost in the only through a reverse proxy on 443 — port 8080 is bound to localhost in the
default compose file. default compose file.
| Artefact | Path | Full walkthrough — fetching the base files, setting up a reverse proxy and
|---|---| TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up
| Compose file (fixed image tag) | [deploy/docker-compose.yml](deploy/docker-compose.yml) | DNS — lives in the operator guide's [Full
| Environment template | [deploy/.env.example](deploy/.env.example) | deployment](docs/guide.md#full-deployment) section, with proxy-specific
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) | commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
| nginx | [deploy/nginx/](deploy/nginx/) |
| Caddy | [deploy/caddy/](deploy/caddy/) |
| Traefik | [deploy/traefik/](deploy/traefik/) |
### 1. Fetch the base files The compose file always publishes **465** and **587**; Postfix listens on 587
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
```sh pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
mkdir -p selfpost/data selfpost/certs && cd selfpost variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml variables](docs/guide.md#environment-variables).
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
cp .env.example .env
```
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
your provider and the certificate your proxy will obtain.
### 2. Reverse proxy and TLS
Pick one proxy. In every case the proxy terminates HTTPS for the panel; the
same certificate must end up under `./certs` as `fullchain.pem` and
`privkey.pem` so Postfix can serve it on 465 (and 587 if enabled). The proxy
must **pass the original `Host` header** — details and rationale:
[Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
`proxy_http` enabled. Copy
[deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) into your
vhost directory, replace `mail.example.com` with your hostname, enable the site,
then issue a certificate:
```sh
sudo certbot --apache -d mail.example.com
```
Point `./certs` at the PEM files certbot wrote (symlink is fine):
```sh
ln -s /etc/letsencrypt/live/mail.example.com certs
```
**nginx (containerised).** From the `deploy/` directory, merge the nginx
fragment and issue the first certificate before nginx can serve HTTPS:
```sh
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
run --rm certbot certonly --webroot -w /var/www/certbot \
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
```
Edit [deploy/nginx/nginx.conf.example](deploy/nginx/nginx.conf.example) and
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
both nginx and SelfPost.
**Caddy (containerised, automatic ACME).** Edit
[deploy/caddy/Caddyfile](deploy/caddy/Caddyfile) and the `<hostname>` placeholders
in [deploy/caddy/docker-compose.caddy.yml](deploy/caddy/docker-compose.caddy.yml),
then:
```sh
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
```
Verify Caddy's on-disk cert path for your version before relying on the
default mount — see the comment at the top of the Caddy compose fragment.
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
[deploy/traefik/docker-compose.traefik.yml](deploy/traefik/docker-compose.traefik.yml),
start the stack, then extract PEM files for Postfix whenever Traefik issues or
renews a certificate:
```sh
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
```
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
### 3. Start SelfPost
If you used Apache on the host (step 2, first option), start only the base
compose file from your `selfpost/` directory:
```sh
docker compose up -d
```
The nginx/Caddy/Traefik fragments from step 2 already include `docker compose up
-d` — skip this if you ran one of those.
**Get the setup URL** — open it in a browser to create the admin account
([first boot](#quick-start)):
```sh
docker compose logs selfpost 2>&1 | grep -m1 'http'
```
```sh
cat ./data/setup-token
```
The file is deleted as soon as setup completes. If logs are shipped to a
central aggregator, prefer `cat ./data/setup-token` so the bearer token does
not enter the log pipeline.
### 4. DNS and sending
Before sending real mail:
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
page → *Re-check*).
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
same time ([DNS setup](docs/guide.md#dns-setup)).
3. Warm up a new IP gradually ([IP warmup](docs/guide.md#ip-warmup)).
### Ports and upgrades
The compose file maps **465** (always) and **587** (when
`SUBMISSION_ENABLE=true`). Bump the pinned image tag deliberately when
upgrading — never use `:latest` ([why](docs/guide.md#fixed-image-tag)).
Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see
[Environment variables](docs/guide.md#environment-variables).
## License ## License
+1 -1
View File
@@ -17,7 +17,7 @@ var documentedPublic = []string{
"TRUSTED_PROXY_CIDR", "TRUSTED_PROXY_CIDR",
} }
// documentedInternal matches guide.md "Internal variables (not part of the operator interface)". // documentedInternal matches architecture.md § Configuration "Internal env vars".
var documentedInternal = []string{ var documentedInternal = []string{
"SELFPOST_DATA_DIR", "SELFPOST_DATA_DIR",
"SELFPOST_DB_PATH", "SELFPOST_DB_PATH",
+48 -7
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"time" "time"
@@ -15,17 +16,57 @@ import (
"github.com/mixeme/selfpost/internal/web" "github.com/mixeme/selfpost/internal/web"
) )
// mailStack is the panel's domain and application services plus the on-disk
// mail-path adapters they write through.
type mailStack struct {
Domains *domain.Service
Apps *app.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
}
func newMailStack(cfg config, st *store.Store) *mailStack {
pf := postfix.New(cfg.postfixDir)
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
}
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
// reloads both daemons — the same work as the Status page's Reload button.
func (m *mailStack) Resync() error {
if err := m.Domains.Resync(); err != nil {
return fmt.Errorf("opendkim resync: %w", err)
}
if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err)
}
return nil
}
func (m *mailStack) skipReloadForTest() {
m.pf.SetReloadHook(func() error { return nil })
m.odk.SetReloadHook(func() error { return nil })
}
// resyncAfterRestore runs one mail-path Resync on the first boot after a
// backup restore. testNoReload skips the supervisord reload step so restore
// tests can verify file regeneration without a running mail stack.
func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
ms := newMailStack(cfg, st)
if testNoReload {
ms.skipReloadForTest()
}
return ms.Resync()
}
// newPanel wires the panel's services over the shared database handle and // newPanel wires the panel's services over the shared database handle and
// builds the HTTP application from cfg. It is the composition of the panel as // builds the HTTP application from cfg. It is the composition of the panel as
// the environment describes it, with nothing bound to a port yet. // the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) { func newPanel(cfg config, st *store.Store) (*web.Server, error) {
// Applications own the SASL accounts and the Postfix sender map; the domain ms := newMailStack(cfg, st)
// service delegates to them when a domain (and its applications) is deleted. return web.New(st, ms.Domains, ms.Apps, web.Config{
pf := postfix.New(cfg.postfixDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
return web.New(st, domains, apps, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled, SubmissionEnabled: cfg.submissionEnabled,
+9 -1
View File
@@ -222,7 +222,8 @@ func run() error {
// touch the database, so schema/format skew between versions cannot corrupt // touch the database, so schema/format skew between versions cannot corrupt
// the restored state. A match consumes the manifest; its absence is the // the restored state. A match consumes the manifest; its absence is the
// normal (non-restore) case. // normal (non-restore) case.
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil { restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
return err return err
} }
@@ -235,6 +236,13 @@ func run() error {
} }
defer st.Close() defer st.Close()
if restored {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
if err := resyncAfterRestore(cfg, st, false); err != nil {
return err
}
}
var wg sync.WaitGroup var wg sync.WaitGroup
errc := make(chan error, 3) errc := make(chan error, 3)
+67 -7
View File
@@ -4,6 +4,7 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -74,13 +75,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
} }
} }
// The daemons read their own state from the archive rather than from // The archive carries the daemon files, and the first boot after restore
// SQLite, so the files have to land where the panel's configuration says // re-derives the maps from SQLite so they stay aligned with the database.
// they are — that is the whole reason restore needs no regeneration step.
for path, want := range map[string]string{ for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} { } {
got, err := os.ReadFile(filepath.Join(r.dataDir, path)) got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil { if err != nil {
@@ -135,6 +135,57 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
} }
} }
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t)
cfg := panelConfig(t, dataDir)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
manifest, err := json.Marshal(backup.Manifest{
Format: backup.FormatFull,
Version: buildinfo.Version,
CreatedAt: "2026-08-14T00:00:00Z",
})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("CheckRestore: %v", err)
}
if !restored {
t.Fatal("CheckRestore did not report a restore")
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
got, err := os.ReadFile(mapPath)
if err != nil {
t.Fatalf("read sender map: %v", err)
}
want := "@" + restoreDomain + " shop\n"
if string(got) != want {
t.Errorf("sender map = %q, want %q", got, want)
}
}
// The version guard is what stops a restore from being silently corrupted by // The version guard is what stops a restore from being silently corrupted by
// schema skew, and it runs before anything opens the database. The manifest // schema skew, and it runs before anything opens the database. The manifest
// stays put on a mismatch: the operator's next move is to start the image the // stays put on a mismatch: the operator's next move is to start the image the
@@ -153,7 +204,7 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
extract(t, archive.Bytes(), target) extract(t, archive.Bytes(), target)
cfg := panelConfig(t, target) cfg := panelConfig(t, target)
err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) _, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err == nil { if err == nil {
t.Fatal("the panel booted on a data directory left by another version") t.Fatal("the panel booted on a data directory left by another version")
} }
@@ -206,7 +257,7 @@ func seedPanelData(t *testing.T) string {
for path, content := range map[string]string{ for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB", filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop", filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent", filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} { } {
full := filepath.Join(dataDir, path) full := filepath.Join(dataDir, path)
@@ -241,12 +292,15 @@ func bootPanel(t *testing.T, dataDir string) http.Handler {
t.Helper() t.Helper()
cfg := panelConfig(t, dataDir) cfg := panelConfig(t, dataDir)
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil { restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err) t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
} }
if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil { if _, err := os.Stat(cfg.manifestPath); err == nil {
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too") t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
} }
}
st, err := store.Open(cfg.dbPath) st, err := store.Open(cfg.dbPath)
if err != nil { if err != nil {
@@ -254,6 +308,12 @@ func bootPanel(t *testing.T, dataDir string) http.Handler {
} }
t.Cleanup(func() { _ = st.Close() }) t.Cleanup(func() { _ = st.Close() })
if restored {
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
}
panel, err := newPanel(cfg, st) panel, err := newPanel(cfg, st)
if err != nil { if err != nil {
t.Fatalf("build the panel: %v", err) t.Fatalf("build the panel: %v", err)
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.2.5 image: ghcr.io/mixeme/selfpost:1.3.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+58 -20
View File
@@ -154,7 +154,10 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. The table below is a summary — HTMX fragment endpoints unless noted. Routes marked **global** return **404** for domain administrators
(`requireGlobal()` in
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`, (`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`. `/system-log/body`, …) and every POST variant live in `web.go`.
@@ -165,17 +168,17 @@ unless noted. The table below is a summary — HTMX fragment endpoints
| `/setup/*` | One-time admin bootstrap | | `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth | | `/login`, `/logout` | Session auth |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | | `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/import` | Domain import (`POST`; form on the Backup page) | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/deliveries` | Send log with filters | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue` | Postfix queue view | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/system-log` | `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Admin username/password and DMARC report address | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | Panel user CRUD (global admin only) | | `/users`, `/users/*` | **Global.** Panel user CRUD |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
@@ -194,13 +197,16 @@ holds the cookie works after process restart, redeploy, or full backup restore.
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change** — all other sessions are deleted; the current session stays - **Password change on `/settings`** — changing your own password deletes
active ([internal/store/sessions.go](../internal/store/sessions.go), every other session for that user; the current session stays active
([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)). [handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
Restoring an **older** backup also restores session rows: a session invalidated Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still has the after that backup was taken can become valid again if the browser still holds
cookie and idle timeout has not expired. the cookie and the restored row's `expires_at` has not passed.
--- ---
@@ -295,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of **Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on `/data` tree, minus `log/`, the setup token and any `tls/`; version check on
restore. Stopped-container `tar` of `./data` is safe (see guide). restore. On the first successful boot after restore, the panel runs one
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, so drift between the extracted archive
and the database is healed before mail flows (same step as `POST /reload` on
demand). Stopped-container `tar` of `./data` is safe (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password → ([internal/secretfile](../internal/secretfile/secretfile.go)): password →
@@ -319,5 +329,33 @@ origin check, no CSRF tokens) are documented there separately.
## Configuration ## Configuration
Public and internal env vars: [guide § Environment variables](guide.md#environment-variables). Public env vars: [guide § Environment variables](guide.md#environment-variables).
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go). Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
**Internal env vars.** The following are read by the panel or startup scripts
but are not part of the operator interface — not meant to be changed in a
normal deployment; documented here so an accidental override reads as
unsupported rather than as a missing doc:
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
`postfix reload` to pick up renewed certificates),
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
hours; logrotate keeps 14 rotated files on a daily schedule, and each
rotation triggers `postfix reload`).
+56 -14
View File
@@ -191,11 +191,12 @@ tag / push only on explicit request (see `release.yml`).
### Release image ### Release image
The release image is published **only** for a SemVer version `X.Y.Z`: a pushed The release image is published **only** for a SemVer version `X.Y.Z`: a
tag `vX.Y.Z`, or a `workflow_dispatch` that supplies that version (or runs on **published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
such a tag). Ordinary commits, and a dispatch from `main` without a version that supplies that version. Pushing a git tag alone does not publish. Ordinary
input, do not publish. The version is the single source that drives the image commits, and a dispatch from `main` without a version input, do not publish.
tag and `-ldflags` in the binaries so they cannot drift apart. The version is the single source that drives the image tag and `-ldflags` in
the binaries so they cannot drift apart.
**Steps (on explicit request):** **Steps (on explicit request):**
@@ -203,8 +204,42 @@ tag and `-ldflags` in the binaries so they cannot drift apart.
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
local-trial image references) in the **same** release commit. local-trial image references) in the **same** release commit.
2. Create and push git tag `vX.Y.Z` on that commit. 2. Create and push git tag `vX.Y.Z` on that commit.
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, 3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`. 4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
page lists only **published** releases. A draft is visible to maintainers only —
it looks like “no releases” to everyone else. CI does not create or publish the
GitHub Release; you do that in the UI. Deleting a releases git tag on GitHub
(or re-pushing tags while cleaning the registry) converts a published release
back into a **draft** — that matches “I published three times and it keeps
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
GHCR package versions, not the git tag.
Push workflow and source changes to **github.com/mixeme/selfpost** before
publishing — Actions reads that repo, not Gitea.
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
GitHub, two things follow:
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
prune remote tags that are absent on Gitea (or re-push with `--force` /
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
Release back to draft. Mirror **branches and new tags forward**; do not
delete release tags on the GitHub side. GHCR cleanup is package versions in
the UI — not `git push github --delete` and not tag prune on the mirror.
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
still points at a commit whose `release.yml` has `on: push: tags` and no
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
`release.yml` on **Publish release** (`release: published`), so mirroring
those tags alone does not start the image build.
Safe mirror: push tags to GitHub without deleting existing ones; keep release
tags on Gitea; publish the GitHub Release on github.com after the mirror has
the tag.
Ordinary commits **do not** publish an image. The compose pin and the git tag Ordinary commits **do not** publish an image. The compose pin and the git tag
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
@@ -290,26 +325,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
`gofmt -l``go vet ./...``go test ./...` (main module, no e2e). `gofmt -l``go vet ./...``go test ./...` (main module, no e2e).
### `release.yml` — push of tag `vX.Y.Z`, or `workflow_dispatch` with SemVer ### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
`prepare` takes the version from the tag (`v1.2.5``1.2.5`) or from the Publishing a GitHub Release runs `release.yml` directly (`release: published`,
`workflow_dispatch` `version` input. A dispatch whose ref is not a `vX.Y.Z` same pattern as gosentry / imap-scrub). You can also run it manually via
tag and whose input is missing or not `X.Y.Z` fails in `prepare` — it must `workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
not publish `ghcr.io/...:main`. run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
`prepare` takes the version from `github.event.release.tag_name` on a release
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
is missing or not `X.Y.Z` fails in `prepare`.
``` ```
prepare (version from tag or workflow_dispatch input) release: published
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm] → build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from prepare) → docker build --load (VERSION from prepare)
→ e2e (test/e2e) → e2e (test/e2e)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64 → push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge → merge
→ docker buildx imagetools create → unified manifest X.Y.Z → docker buildx imagetools create → unified manifest X.Y.Z
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
``` ```
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
emulation for e2e is impractical. E2e first, then push — the registry receives emulation for e2e is impractical. E2e first, then push — the registry receives
the bytes that passed the gate. the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
tagged in GHCR; per-arch names exist briefly during the merge job.
A failed e2e **blocks** image publication. A failed e2e **blocks** image publication.
+523 -269
View File
@@ -3,55 +3,53 @@
Detailed install, configuration, and day-to-day operations. For a short Detailed install, configuration, and day-to-day operations. For a short
overview and quick start, see [README.md](../README.md). 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).
## Table of contents ## Table of contents
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory) - [Installation](#installation)
- [Local trial](#local-trial) - [Ports](#ports)
- [Environment variables](#environment-variables) - [Local trial](#local-trial)
- [DNS setup](#dns-setup) - [Initial setup](#initial-setup)
- [IP warmup](#ip-warmup) - [Full deployment](#full-deployment)
- [Operations](#operations) - [Fixed image tag](#fixed-image-tag)
- [Rate limiting](#rate-limiting) - [Environment variables](#environment-variables)
- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain) - [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Instance administration](#instance-administration)
- [Status](#status)
- [Mail queue and System log](#mail-queue-and-system-log)
- [Settings](#settings)
- [Users](#users)
- [Sessions](#sessions)
- [Upgrading](#upgrading)
- [Container health](#container-health)
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
- [Full backup and restore](#full-backup-and-restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export) - [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Published ports](#published-ports) - [Domain administration](#domain-administration)
- [Fixed image tag](#fixed-image-tag) - [Domains page](#domains-page)
- [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)
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
## Reverse proxy (mandatory) ## Installation
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse ### Ports
proxy in front of it is not optional. The proxy is also the project's only
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
service share one hostname — the common case — it's genuinely one certificate
serving both.
SelfPost isn't tied to a specific proxy; pick whichever fits your host: `deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
(smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
but it can look like an open port in external scans.
| Proxy | Where certs live | Fragment | ### Local trial
|---|---|---|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
Apache is the recommended default because the certbot Apache plugin already
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
extra moving parts between "certificate issued" and "Postfix can read it."
**The proxy needs no security configuration of its own.** The panel emits its
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
that's easy to get wrong lives in the service rather than in a config file
somebody edits under pressure. There is exactly one thing the proxy must do:
**pass the original `Host` header through**. All four fragments above already
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
reject every form submission as cross-origin — the log says so explicitly,
printing the `Origin` and `Host` it compared.
## Local trial
The [README quick start](../README.md#quick-start) runs a single container The [README quick start](../README.md#quick-start) runs a single container
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
@@ -85,7 +83,99 @@ Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN). (and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
Clients must skip TLS verification — the cert is not from a public CA. Clients must skip TLS verification — the cert is not from a public CA.
## Environment variables ### Initial setup
On first start the one-time setup URL is printed in the container log
(`docker compose logs -f`) and written to `/data/setup-token` inside the
container — `./data/setup-token` on the host, mode `0600` — then deleted when
setup completes. The link is `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
file:
```sh
docker compose exec selfpost cat /data/setup-token
```
### Full deployment
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
only through a [reverse proxy](#reverse-proxy-mandatory) on 443 — port 8080 is
bound to localhost in the default compose file.
| Artefact | Path |
|---|---|
| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) |
| Environment template | [deploy/.env.example](../deploy/.env.example) |
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | [deploy/nginx/](../deploy/nginx/) |
| Caddy | [deploy/caddy/](../deploy/caddy/) |
| Traefik | [deploy/traefik/](../deploy/traefik/) |
**1. Fetch the base files.**
```sh
mkdir -p selfpost/data selfpost/certs && cd selfpost
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
cp .env.example .env
```
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
your provider and the certificate your proxy will obtain. See [Environment
variables](#environment-variables) for the full list.
**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy
(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. The same
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`
so Postfix can serve it on 465 (and 587 if enabled).
**3. Start SelfPost.** If you used Apache on the host (the recommended
option), start only the base compose file from your `selfpost/` directory:
```sh
docker compose up -d
```
The nginx/Caddy/Traefik fragments already include `docker compose up -d`
skip this if you ran one of those.
**Get the setup URL** — open it in a browser to create the admin account (see
[Initial setup](#initial-setup)):
```sh
docker compose logs selfpost 2>&1 | grep -m1 'http'
```
```sh
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).
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)).
#### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.3.0`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's
embedded version and the image tag that produced it are the same value by
construction (the release CI stamps both from one git tag — see
`.github/workflows/release.yml`), so the pin is what makes "restore into the
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
deliberately, not by riding a moving target — see [Upgrading](#upgrading).
### Environment variables
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
`docker-compose.yml`. The table below lists every variable an operator is `docker-compose.yml`. The table below lists every variable an operator is
@@ -95,7 +185,7 @@ 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` | | `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` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.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](#rate-limiting). | `100` | `.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` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `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` | | `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` |
@@ -106,119 +196,128 @@ TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs` [deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
bind mount — configure the mount, not these variables. bind mount — configure the mount, not these variables.
**Internal variables (not part of the operator interface).** The following are The image also reads a number of internal, non-operator env vars (paths,
read by the panel or startup scripts but are not meant to be changed in a timeouts, tuning) — not part of this interface; see
normal deployment; documenting them here avoids treating accidental overrides as [architecture.md § Configuration](architecture.md#configuration) if you need
supported configuration: them.
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH` ### Reverse proxy (mandatory)
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
`postfix reload` to pick up renewed certificates),
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
hours; logrotate keeps 14 rotated files on a daily schedule, and each
rotation triggers `postfix reload`).
## DNS setup SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
proxy in front of it is not optional. The proxy is also the project's only
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
service share one hostname — the common case — it's genuinely one certificate
serving both.
Two different scopes — don't confuse them: SelfPost isn't tied to a specific proxy; pick whichever fits your host:
**Server level (once, for the machine itself):** | Proxy | Where certs live | Fragment |
- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most |---|---|---|
receiving mail servers weigh this heavily; get it from whoever assigns the IP | **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
(hosting provider's panel/support), not from your own DNS zone. | nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
**Domain level (for *every* sending domain you add in the panel):** Apache is the recommended default because the certbot Apache plugin already
- **SPF** — a TXT record on the domain authorizing this server to send on its writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup). extra moving parts between "certificate issued" and "Postfix can read it."
- **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.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain
often has no inbox, so `rua=` is optional — configure a default report address
in *Settings* or per domain when you have a mailbox that receives inbound mail
elsewhere. 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.
Skipping any of the three per-domain records is the single most common reason **The proxy needs no security configuration of its own.** The panel emits its
mail lands in spam even though SelfPost delivered it correctly — DKIM passing own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the `X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
panel, add its DNS records at the same time**, not later. that's easy to get wrong lives in the service rather than in a config file
somebody edits under pressure. There is exactly one thing the proxy must do:
**pass the original `Host` header through**. All four fragments above already
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
reject every form submission as cross-origin — the log says so explicitly,
printing the `Origin` and `Host` it compared.
The panel checks both scopes for you and tells you what is actually published: In every case the proxy terminates HTTPS for the panel; the resulting
the *Status* page verifies the server's hostname and its reverse record certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
card 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. 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 reported as "cannot tell" rather than as a failure.
## IP warmup **Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
`proxy_http` enabled. Copy
[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your
vhost directory, replace `mail.example.com` with your hostname, enable the site,
then issue a certificate:
A brand-new IP has no sending history, so receiving servers are cautious with ```sh
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to sudo certbot --apache -d mail.example.com
a domain, increase gradually over days/weeks rather than sending everything on ```
day one, and check the IP against major blocklists (Spamhaus and similar)
before and during warmup. This is inherent to how mail reputation works on the
public internet, not something SelfPost's configuration can shortcut.
## Operations Point `./certs` at the PEM files certbot wrote (symlink is fine):
```sh
ln -s /etc/letsencrypt/live/mail.example.com certs
```
**nginx (containerised).** From the `deploy/` directory, merge the nginx
fragment and issue the first certificate before nginx can serve HTTPS:
```sh
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
run --rm certbot certonly --webroot -w /var/www/certbot \
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
```
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
both nginx and SelfPost.
**Caddy (containerised, automatic ACME).** Edit
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
in [deploy/caddy/docker-compose.caddy.yml](../deploy/caddy/docker-compose.caddy.yml),
then:
```sh
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
```
Verify Caddy's on-disk cert path for your version before relying on the
default mount — see the comment at the top of the Caddy compose fragment.
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml),
start the stack, then extract PEM files for Postfix whenever Traefik issues or
renews a certificate:
```sh
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
```
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 After sign-in the panel opens on **Status** — the place to answer "is the
service healthy and will mail be accepted?" service healthy and will mail be accepted?"
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel), ### Status
TLS certificate validity and expiry, milter socket presence, and a short
Postfix queue summary. The **Machine** card adds the resource usage of the `/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
host underneath — processor (core and thread counts), memory and swap, and certificate validity and expiry, milter socket presence, and a short Postfix
per-interface network throughput and totals — read from the kernel's queue summary. The **Machine** card adds the resource usage of the host
counters; CPU and throughput are measured between refreshes, so they appear underneath — processor (core and thread counts), memory and swap, and
one refresh after the page opens. A fully busy processor or a machine out of per-interface network throughput and totals — read from the kernel's
memory is a warning here, because both delay or kill the mail path; counters; CPU and throughput are measured between refreshes, so they appear
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME` one refresh after the page opens. A fully busy processor or a machine out of
against the PTR record the internet publishes for this server's IP memory is a warning here, because both delay or kill the mail path;
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
**Reload configuration** button re-applies OpenDKIM tables and the Postfix against the PTR record the internet publishes for this server's IP
sender map from the database — use it if daemons drifted from what the panel (forward-confirmed reverse DNS) — see
shows after manual edits under `/data`. [Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits Postfix sender map from the database — use it if daemons drifted from what
(level 2) and trusted-IP application overrides are configured here. the panel shows after manual edits under `/data`.
*Export domain* writes a single-domain archive;
*Import a domain* on the Backup page reads one back in. ### Mail queue and System log
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
by domain and application. 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); *Details* opens that row's own page
(`/deliveries/{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 — and, under both, 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 controlled by
`SEND_LOG_RETENTION_DAYS`.
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring. trying to deliver or deferring.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and - **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
@@ -227,69 +326,88 @@ service healthy and will mail be accepted?"
hours. It lives in the data volume, so it survives a container recreate along hours. It lives in the data volume, so it survives a container recreate along
with the rest of the state — `./data/log/` on the host — but it is *not* with the rest of the state — `./data/log/` on the host — but it is *not*
included in backups: it is diagnostics, not state. included in backups: it is diagnostics, not state.
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
the domain-import form (`POST /domains/import`). See ### Settings
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
- **Settings** (`/settings`) — change the administrator username and/or `/settings` changes the signed-in user's username and/or password. **Global
password, and the panel-wide default DMARC report address (`rua=`) offered administrators** also set the panel-wide default DMARC report address (`rua=`)
when a domain doesn't set its own — see [DNS setup](#dns-setup). Application offered when a domain doesn't set its own — see
SASL logins are separate and are not changed here. [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
- **Users** (`/users`, global administrator only) — create, edit, and delete are separate and are not changed here.
panel users. There are two roles:
- **Global administrator** — full access to every page and every domain, ### Users
`/users` (global administrator only) creates, edits, and deletes panel users.
There are two roles:
- **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, and System log. including Users, Backup, Status, Mail queue, and System log.
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; `/users`, `/backup`, `/status`, `/mail-queue`, and Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/system-log` are not reachable (404). A domain-admin can *export* the `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
domains assigned to them — see the note on working credentials below. 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).
The panel refuses to remove or demote the **last** global administrator, so The panel refuses to remove or demote the **last** global administrator, so
it can never end up with none. it can never end up with none.
**Sessions.** A login survives a container restart: sessions live in SQLite, not ### Sessions
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
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 seven days) with no absolute lifetime cap — an admin who keeps using the panel
stays signed in indefinitely. HTMX polling on the monitoring screens stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does (Deliveries, Mail queue, System log, and the Status health fragment) does
**not** count as activity, so an auto-refreshing tab left open will not keep a **not** count as activity, so an auto-refreshing tab left open will not keep a
session alive forever. Changing the password signs out every other session but session alive forever. Changing **your own** password on `/settings` signs out
leaves the current browser signed in. every other session for that user but leaves the current browser signed in.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
**Upgrading.** Bump the pinned image tag in `docker-compose.yml` to the target ### Upgrading
release, then `docker compose up -d`. The backup version check requires the
running image to match the version that created a full backup — see [Fixed image Bump the pinned image tag in `docker-compose.yml` to the target release, then
`docker compose up -d`. The backup version check requires the running image
to match the version that created a full backup — see [Fixed image
tag](#fixed-image-tag). tag](#fixed-image-tag).
**Container health.** The image declares a Docker `HEALTHCHECK` that probes ### Container health
`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when
OpenDKIM, the panel, and 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** page 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.
**First-time setup link.** On first start the one-time setup URL is printed in The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
the container log (`docker compose logs -f`) and written to `/data/setup-token` 8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
inside the container — `./data/setup-token` on the host, mode `0600` — then Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
deleted when setup completes. The link is catches a dead mail path that would still leave the HTTP server up, but it
`https://<SELFPOST_HOSTNAME>/setup/<token>` (path token, not a query string), does **not** verify TLS certificates, DNS records, or end-to-end delivery —
valid for ten minutes. If this host ships container logs to a central use the authenticated [Status](#status) page for that. External monitoring
aggregator, prefer reading the file: can use the same endpoint through the reverse proxy if you expose it, or poll
`docker inspect` health state on the host.
```sh ### Server-level DNS (PTR/rDNS)
docker compose exec selfpost cat /data/setup-token
```
## Rate limiting Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at
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.
SelfPost applies two independent layers; both can refuse a submission, but only The [Status](#status) page verifies the server's hostname against this
level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the record (forward-confirmed reverse DNS). Results are cached for about one
panel cannot exceed level 1 (the panel shows the level-1 values and rejects minute; use *Re-check* right after publishing a record.
higher numbers).
**Level 1 (IP backstop)** — always on, configured via `.env`: Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
### Rate limiting — level 1 (IP backstop)
SelfPost applies two independent layers of rate limiting; both can refuse a
submission, but only level 2 (domain/application, see
[Domain administration](#rate-limiting--level-2-domain-and-application))
writes a `rejected` row in the send log. Level-2 ceilings set in the panel
cannot exceed level 1 (the panel shows the level-1 values and rejects higher
numbers).
Level 1 is always on, configured via `.env`:
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit` - `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit` - `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
@@ -297,81 +415,117 @@ higher numbers).
This is an anvil limit per connecting client IP. It keeps working even if the This is an anvil limit per connecting client IP. It keeps working even if the
journal-milter (level 2) is down. There is no per-IP bypass. journal-milter (level 2) is down. There is no per-IP bypass.
**Level 2 — domain** — optional, on each domain's page. A message ceiling and ### Full backup and restore
window for **every** client IP sending as that domain. When unset, only
level 1 applies for non-privileged senders.
**Level 2 — application (trusted IPs)** — optional override on an application: **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
list one or more client IPs and a ceiling **strictly above** the domain limit all applications' SASL credentials, `manifest.json` with the version that
(still ≤ level 1). Connections from those IPs use the application ceiling and created it): panel button (*Backup* → *Full backup*), or from the host:
skip the domain check. Other IPs stay under the domain limit (or level 1 alone).
An application override without trusted IPs is inactive.
When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is ```sh
recorded in Deliveries as `rejected`. docker exec <container> selfpost-backup > selfpost-backup.tar.gz
```
**Level 2 is best-effort, not a guarantee.** It runs inside the journal-milter **Restore** means unpacking that archive into a fresh `/data` bind mount and
and is deliberately fail-open: if the rate-limit lookup hits a store error, or starting a container of the **exact same image version** that created it —
the connecting client's IP is not available to the milter, level 2 is skipped SelfPost refuses to start otherwise and tells you which tag to use. On the
and the message is accepted rather than held up. Level 1 (the Postfix anvil first successful start after restore, `manifest.json` from the archive is
limit above) is the backstop that keeps working even when level 2 cannot run. **deleted** — it guards only that one boot, so a later in-place upgrade is
not blocked. On that same first boot the panel also runs one **Resync**
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both
daemons are reloaded, healing any drift between the extracted files and the
database (the Status page's *Reload configuration* button runs the same step
on demand). This is why the compose file pins a fixed tag rather than
`:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)).
## Backup, restore, and moving a single domain **Restoring in place** (same host — recovering from data loss, or rolling
back after a bad change):
Two related but distinct operations ```sh
([architecture.md](architecture.md) § Persistence): # 1. Stop the instance being replaced
docker compose down
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, # 2. Move the current /data aside rather than deleting it, start from empty
all applications' SASL credentials, `manifest.json` with the version that mv ./data ./data.before-restore
created it): panel button (*Backup* → *Full backup*), or from the mkdir ./data
host:
```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
```
**Restore** means unpacking that archive into a fresh `/data` bind mount and
starting a container of the **exact same image version** that created it —
SelfPost refuses to start otherwise and tells you which tag to use. On the
first successful start after restore, `manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is
not blocked. This is why the compose file pins a fixed tag rather than
`:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires.
Restoring an archive taken **before** you invalidated a session (password # 3. Unpack the backup into the fresh directory
change, logout everywhere) can bring that session back: session rows travel tar xzf selfpost-backup.tar.gz -C ./data
with the backup, and a browser that still holds the matching cookie is
logged in again once the idle timeout allows it. If a restore might do this,
changing every user's password afterwards clears it out.
**Alternative: archive `./data` while stopped.** If the service can be taken # 4. docker-compose.yml must already pin the exact tag the backup was made
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on # with — check the archive's manifest if unsure:
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup tar xzf selfpost-backup.tar.gz -O manifest.json
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
bulk of the archive; add `--exclude=./data/log` if you only want the state.
Do **not** tar `./data` while
the container is running: the database uses WAL mode and a naive copy can
capture an inconsistent snapshot. The panel/CLI backup remains preferable when
you cannot afford downtime because it takes a consistent SQLite snapshot via
the Backup API on a live container.
- **Export/import a single domain** (domain page → *Export domain* to write the # 5. Start it and watch the boot
file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working** docker compose up -d
SASL passwords — to a different SelfPost instance without regenerating docker compose logs -f selfpost
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full ```
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.
Both files are **secrets** — they contain the admin password hash (full A version mismatch at step 5 refuses to start and leaves `/data` untouched —
backup) or working application credentials (domain export) in the clear or in the panel exits with a message naming the tag to use, e.g.:
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.
### Encrypting a backup or export ```
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3)
```
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
-d` again — the manifest is still there because the failed boot never got to
delete it.
**Moving to a different host** is the same five steps, just starting cold:
bring the compose files and the correct pinned image tag to the new host
(step 1 of [Full deployment](#full-deployment)), put the backup archive in
place of step 3 above, then redo the reverse-proxy/TLS and DNS steps of a
[Full deployment](#full-deployment) — the PTR record and the certificate both
belong to the old IP/host and have to be reissued for the new one; nothing in
the backup carries them.
**Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
does not read `/data` and performs no version check. Start one normally
(step 5, but on an empty `/data` you haven't unpacked yet), then:
```sh
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
```
Stop it, wipe `/data` again, and continue from step 2 above with the
resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password
options.
Restoring an archive taken **before** a session row was removed can bring
that session back: session rows travel with the backup, and a browser that
still holds the matching cookie is signed in again on the next request if the
restored row's idle expiry has not passed. `POST /logout` removes only the
current session; there is no "logout everywhere". Changing your own password
on `/settings` deletes your other sessions, but a global administrator
resetting another user's password on `/users` does not invalidate that user's
existing sessions.
**Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
bulk of the archive; add `--exclude=./data/log` if you only want the state.
Do **not** tar `./data` while the container is running: the database uses
WAL mode and a naive copy can capture an inconsistent snapshot. The
panel/CLI backup remains preferable when you cannot afford downtime because
it takes a consistent SQLite snapshot via the Backup API on a live
container.
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`).
Both a full backup and a domain export are **secrets** — they contain the
admin password hash (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.
#### Encrypting a backup or export
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the 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: file that comes down is an encrypted envelope instead of the plain archive:
@@ -411,21 +565,121 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
With no password set, the CLI keeps writing the plain `.tar.gz` it always has. With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
## Published ports ## Domain administration
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 ### Domains page
(smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
but it can look like an open port in external scans.
## Fixed image tag `/domains` lists sending domains and hosts the add-domain form (**global
administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application
overrides are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads
one back in (**global administrator only**) — see [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain).
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), ### Domain-level DNS (SPF, DKIM, DMARC)
deliberately never `:latest`. The current pin is `1.2.5`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that For *every* sending domain you add in the panel:
image was published. Pinning matters because of the backup version check above:
the panel binary's embedded version and the image tag that produced it are the - **SPF** — a TXT record on the domain authorizing this server to send on its
same value by construction (the release CI stamps both from one git tag — see behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
`.github/workflows/release.yml`), so the pin is what makes "restore into the - **DKIM** — a TXT record with the exact value the panel shows on that
same version" a checkable fact rather than a guess. Upgrade by bumping the tag domain's page (`domain page → DKIM TXT record`), one selector per domain.
deliberately, not by riding a moving target. - **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain
often has no inbox, so `rua=` is optional — configure a default report address
in *Settings* (see [Settings](#settings)) or per domain when you have a
mailbox that receives inbound mail elsewhere. 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.
Skipping any of the three records is the single most common reason mail
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
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.
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
reported as "cannot tell" rather than as a failure.
Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope
— see [Server-level DNS](#server-level-dns-ptrrdns).
### IP warmup
A brand-new IP has no sending history, so receiving servers are cautious with
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
a domain, increase gradually over days/weeks rather than sending everything on
day one, and check the IP against major blocklists (Spamhaus and similar)
before and during warmup. This is inherent to how mail reputation works on the
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
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`.
**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
senders.
**Level 2 — application (trusted IPs)** — optional override on an
application: list one or more client IPs and a ceiling **strictly above**
the domain limit (still ≤ level 1). Connections from those IPs use the
application ceiling and skip the domain check. Other IPs stay under the
domain limit (or level 1 alone). An application override without trusted
IPs is inactive.
**Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
a store error, or the connecting client's IP is not available to the
milter, level 2 is skipped and the message is accepted rather than held up.
Level 1 is the backstop that keeps working even when level 2 cannot run.
### Deliveries
`/deliveries` is a searchable send log with server-side filters by domain
and application. 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
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 — and, under
both, 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 controlled by
`SEND_LOG_RETENTION_DAYS`.
### 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 and its
applications' **working** SASL passwords — 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.
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).
-661
View File
@@ -1,661 +0,0 @@
# Plan: code-review (full-tree review follow-ups)
**Status:** agreed
**Date:** 2026-08-13
**Reviewer:** Cursor Grok 4.6 (whole-tree authorship review). This is **not**
the Fable pre-release security audit in [development.md](../development.md) §
Model routing; a Fable pass is a later step on the P0 diff.
**Version:** patch for defects; docs/UI follow-ups have no schema.
**Order:** **P0 before inbound-relay.** P0 is a shipped RBAC hole, not a
feature. Remaining phases after P0, or interleaved with inbound-relay by
agreement.
---
## Goal
Record the 2026-08-13 full-tree review (architecture, complexity, quality,
docs, maintainability, logic, refactor, licence, legacy, stubs, GUI, disputed
decisions, edge cases, tests, duplication) and a phased implementation
checklist with a recommended model per step, using the routing table in
[development.md](../development.md).
---
## Verdict
SelfPost is a compact, well-bounded 1.x product: one image, one SQLite file,
thin `domain`/`app` services for multi-store writes, a fail-open journal
milter with a Postfix level-1 backstop, and documentation that is unusually
honest about accepted risks. Complexity matches the scale (~10.7k production
Go lines, ~5.8k unit-test lines, ~1.2k HTML, 749 CSS, 281 JS). Comments
explain decisions rather than restating code.
The outstanding defect is **send-log authorization for domain administrators**
(confidentiality). After that, the work is tightening a few fail-open paths,
catching docs that froze at “single administrator”, small GUI bugs from the
1.2.x layout pass, and filling test gaps around auth/RBAC. Do **not** use this
review as a licence to rewrite layers, squash migrations in 1.x, or add CSRF tokens
without revisiting the ADR.
---
## How to read this file
Findings are grouped by the sixteen review questions. Each finding has a
severity (**H**igh / **M**edium / **L**ow / **I**nfo). The implementation
checklist at the end is the work queue; it names the model for each step.
**Models** (from [development.md](../development.md)):
| Kind of work | Model |
|---|---|
| Security, infra, mail path, permissions, open-relay risk | **Opus** |
| UI / JS / CSS, templates, documentation (English) | **Sonnet** |
| Trivial mechanics: retarget links, grep, compose bump, comment fixes | **Haiku** |
| Security **review** of a diff (not authorship) | **Fable** |
Reviewers must not be the author of the code under review.
---
## 1. Architecture / structure
**Proportionate.** Composition root in `cmd/panel` (HTTP + journal-milter +
log-tailer, one `*store.Store`). `internal/domain` and `internal/app` own
multi-store writes and rollback. Adapters (`postfix`, `milter`, `logtail`,
`dnscheck`, `health`, `backup`, `secretfile`) are the only infrastructure-aware
code. Interfaces exist where they break import cycles or enable fakes
(`domain.Applications`, `app.SenderMaps`, `milter.Store`, `logtail.StatusStore`)
— not as a DI framework.
Handlers may call `store` directly for single-table reads (documented in
[architecture.md](../architecture.md) § Code layers). That is followed for
sessions, send-log queries, users, and DMARC settings. It is not a layering
violation; it is an incomplete service boundary that will hurt if those
surfaces keep growing.
No circular Go imports. `MaxOpenConns(1)` on SQLite is an intentional
single-connection trade-off for the three in-process roles.
| Sev | Finding |
|---|---|
| **M** | Users, send-log listing, and global DMARC settings have no service; handlers talk to `store`. Fine at current size; do not invent a service until a second writer appears. |
| **L** | `auth.RequireGlobal` (`internal/web/auth/middleware.go`) is unused; handlers duplicate `requireGlobal`. Either wire the middleware on `/users`, `/backup`, `/status`, `/mail-queue`, `/system-log` or delete the unused helper. |
| **L** | `HandleAccount` / `handlers_account.go` still use the pre-1.2.3 “account” name while the route is `/settings`. |
| **I** | Package comment on `internal/store` still says “the administrator account” after migration `0005` replaced `admin` with `users`. |
**Do not:** introduce a repository layer, split the panel binary, or move
SQLite behind an interface “for testability” — the existing fakes are enough.
---
## 2. Complexity vs project scale
The code is **not over-engineered**. A few files are large because the problem
is large, not because of unused abstraction:
| File | ~Lines | Note |
|---|---|---|
| `internal/health/machine.go` | 622 | Cohesive `/proc` sampler |
| `internal/logtail/logtail.go` | 528 | Follow + rotate + reconcile + retention |
| `internal/web/handlers/handlers_monitor.go` | 481 | Send-log UI + authz (this is where P0 lives) |
| `internal/web/view/templates/domain_detail.html` | 476 | DNS + apps + limits + export; composition debt |
| `internal/secretfile/secretfile.go` | ~423 | Isolated crypto envelope |
| `internal/web/view/static/panel.css` | 749 | Tokens + layout; comment-heavy by design |
Comments are long and mostly load-bearing (threat, fail-open, why not the
obvious alternative). The cost is scanability: some files are 3040% prose.
That matches the projects disclosed AI-authorship style
([development.md](../development.md) § Authorship). Do not strip comments in
the name of “cleanup”. Update the stale ones (see §4).
---
## 3. Code quality
Naming matches the docs (`domain` / `application`, level-1 / level-2). Errors
on the mail path log-and-continue (intentional fail-open). Panel paths log and
return 4xx/5xx. `crypto/rand` failure panics in `auth/token.go` — acceptable.
Context is used for process lifetime and DNS timeouts, not for SQLite (correct
with one connection). Dashboard DNS checks write distinct `rows[i]` from
goroutines; Go 1.22+ loop semantics make that safe (`go.mod` is 1.26).
Magic numbers are mostly named (`reservationTTL`, `renewThreshold`, CSP/HSTS).
Env defaults live in `loadConfig`.
| Sev | Finding |
|---|---|
| **M** | `sessionStore.Create` logs a DB error and still returns the token (`internal/web/auth/session.go`). Login sets the cookie; the next request bounces to `/login`. Fail closed: no cookie, error page. |
| **M** | `app.Service.Delete` removes the registry row **before** SASL / rate-limit cleanup. SASL failure → orphaned `sasldb2` account that can still authenticate. Domain delete does SASL first (`domain/service.go`). Align app delete with that order (or compensate: restore the row on SASL failure). |
| **L** | Login/setup `rateLimiter` sweeps expired buckets only when creating a **new** key. Many unique IPs grow the map until restart. Cap the map or sweep on a timer. |
| **L** | `parseTrustedProxies` skips invalid CIDRs instead of refusing to start. Silent misconfiguration of `TRUSTED_PROXY_CIDR`. |
---
## 4. Documentation completeness vs code; comments
Docs are a first-class artefact (env regression test, architecture as-built,
security accepted-risks). The drift is concentrated where **domain-admin
shipped in 1.2.0** and several files still argue “single-user”.
| Sev | Finding |
|---|---|
| **M** | [guide.md](../guide.md) Operations never mentions **Users** (`/users`) or the domain-admin role. Architecture and product do. An operator reading only the guide does not know the panel is multi-user. |
| **M** | [security.md](../security.md) CSRF ADR still says the panel is single-user and “revisit if multi-user”. Multi-user shipped. The origin-check decision can stand; the **rationale and revisit trigger must be rewritten**. |
| **M** | [security.md](../security.md) says passwords are “bcrypt (or argon2)”. Code is bcrypt only. |
| **M** | `internal/backup` package comment claims the panel **regenerates** Postfix/OpenDKIM maps from SQLite on every start after restore. Startup only runs `CheckRestore` (`cmd/panel/main.go`). Maps/keys are **in** the tarball. Heal path is the Status **Reload** button. |
| **L** | Architecture route table omits `/license` and the `/account``/settings` 308. |
| **L** | Guide Settings section: “change the administrator username and/or password” — global Settings also has the default DMARC `rua=` address. |
| **L** | Guide does not warn that restoring an **older** backup can resurrect sessions (architecture does). |
| **L** | Guide rate-limiting section does not stress that level 2 is **fail-open** (store error or missing client IP → mail continues; level 1 is the backstop). |
| **L** | [roadmap.md](../roadmap.md) and [plans/dmarc-reports.md](dmarc-reports.md) still say `admin.dmarc_report_email` after `0005` moved it to `settings`. |
| **L** | `setupManager` comments still say “admin row”; the fact is `users` / `UserExists()`. “Plan B.1 / C.4” comments are opaque to outsiders; keep them, they are history, not errors. |
| **I** | E2e coverage summary in development.md omits logrotate and supervisor-process checks that actually run. |
| **I** | [plans/logrotate-mode.md](logrotate-mode.md) is **done** but still in `docs/plans/` (active-plans directory). History belongs in git / CHANGELOG. |
Comments in production code are generally **high quality**. Missing comments
are on domain-admin authorization policy in `sendLogData` (the P0 hole has no
comment stating the intended invariant) and on `rateLimiter` memory bounds.
---
## 5. Human readability and maintainability
A new maintainer can follow the tree from [architecture.md](../architecture.md)
into `cmd/panel``internal/web/web.go` → services. Tests document *why*
(milter in-flight, queue-id anchoring, CSRF matrix).
Friction:
- `domain_detail.html` is the hardest HTML file to edit (repeated DNS
host/type/value blocks, checkbox “Edit” panels).
- `panel.css` structure-tied selectors (`.muted + form > select:first-of-type`)
will break on a copy change.
- Dual `CurrentUser` + `Principal` is redundant but works (`withPrincipal`
sets both).
- `assignedDomains` loads **all** domains then filters in Go, while
`store.listUserDomainNames` already exists and is unused by handlers.
None of this blocks maintenance at current size. Prefer small extractions
(DNS partial, `tryAdmit`) over a layer rewrite.
---
## 6. Logical errors
### H — Domain-admin send-log list leaks other domains
Detail page checks membership (`HandleDelivery`). The **list** does not.
`sendLogData` in `internal/web/handlers/handlers_monitor.go`:
- Empty `SendLogFilter.Domain` means “all rows” (`internal/store/sendlog.go`).
- For a non-global user, a disallowed `?domain=` is cleared to `""`. The
assigned domain is filled in **only when there is exactly one**.
- A domain-admin with **0 or ≥2** assigned domains and no (or a forged)
domain filter therefore sees **every** send-log row (From, To, Subject).
- `?app=` is applied to SQL **before** it is checked against the users
application logins. The allowlist only updates the templates selected
filter. Forged `?app=<foreign-login>` with an empty domain filter returns
that applications rows.
The deliveries tables domain dropdown still lists only assigned domains, so
the leak is silent.
**Invariant to implement:** a non-global principals `QuerySendLog` /
`CountSendLog` are always constrained to assigned domain names; if that set is
empty, the result is empty. Validate `AppLogin` against the allowlist
**before** the query.
### M — Level-2 check/reserve race
`enforceLimit` calls `flight.count` then `flight.reserve` under **separate**
mutex acquisitions (`internal/milter/ratelimit.go`, `inflight.go`). Two MAIL
FROM handlers can both observe `n == max-1` and both reserve. In-flight
tracking closes the *stored-count* race (and
`TestRateLimitCountsInFlightMessages` covers the **sequential** case). It does
not close parallel check-then-act. Severity is tempered by fail-open and
Postfix level-1. Fix: one `tryAdmit(key, since, max)` under the inflight
mutex.
### M — Session create fail-open
See §3. Not a stolen-session bug (hash never lands in the DB); it is a
logged-in-looking cookie that cannot be looked up.
### M — App delete ordering
See §3. Orphaned SASL is a mail-path consistency bug.
### L — Domain export `Version` ignored on import
`internal/domain/transfer.go` stamps `buildinfo.Version`; import checks format
only. Lower risk than full-backup `CheckRestore`; still a cross-version footgun.
### I — Journal milter fail-open; origin CSRF fail-open; queue-reconcile
`bounced`
Documented accepted risks in [security.md](../security.md). Not defects.
Revisit the CSRF ADRs *framing* (multi-user), not necessarily the mechanism.
---
## 7. Refactoring and optimisation
Worth doing, in order:
1. `tryAdmit` (correctness, not speed).
2. `SendLogFilter` domain IN-list (correctness).
3. DNS field partial + settings credentials partial (drift).
4. One helper for the five `panel.js` show/hide field pairs.
5. `assignedDomains` via SQL for the current user (clarity, not performance).
Not worth doing now:
- Service layer for users / send-log.
- Replacing SQLite, HTMX, or the single-container model.
- Squashing migrations `0001``0005`.
- CSRF tokens (see §12).
- Rewriting `machine.go` or `logtail.go` for size.
---
## 8. Licence (AGPL-3.0)
Packaging is largely correct: root `LICENSE` ≡ embedded `internal/legal/LICENSE`
(test), unauthenticated `/license`, footer copyright + Source + “No warranty”
on login/setup, image copies `LICENSE`/`NOTICE`, Go deps are BSD-family, htmx
is 0BSD. Network-use §13 is stated in `NOTICE` and the README.
| Sev | Finding |
|---|---|
| **M** | IBM Plex WOFF2 files are shipped without the SIL OFL 1.1 text. OFL requires the licence to travel with the font. Add `OFL.txt` next to the fonts (and mention the path in `NOTICE`). |
| **M** | `NOTICE` tells modifiers to change the Source URL in `layout.html`. The URL is `legal.SourceURL` in `internal/legal/legal.go`, injected by `view.go`. |
| **L** | `/license` serves LICENSE only, not NOTICE. Optional: serve NOTICE at `/notice` or append attributions. |
| **L** | SPDX headers only on `cmd/panel` and `cmd/selfpost-backup`. AGPL does not require per-file SPDX; either add them everywhere or drop the two so the convention is consistent. |
| **I** | Debian package licences are pointed at packages.debian.org rather than a pinned list — normal for an image that installs from bookworm. |
No AGPL-incompatible Go dependency found in `go.mod`.
---
## 9. Legacy code and migrations
| Migration | Role | Removal |
|---|---|---|
| `0001_init.sql` | Core schema (including historical `admin`) | Keep for all **1.x** (`PRAGMA user_version` chain) |
| `0002_sessions.sql` | DB sessions | Keep for 1.x |
| `0003_logtail_state.sql` | Tailer offset | Keep for 1.x |
| `0004_dmarc_report_email.sql` | DMARC columns on `admin` | Keep for 1.x; `0005` moves the data |
| `0005_panel_users.sql` | `users` / `user_domains`; `DROP TABLE admin` | Keep for 1.x |
Squash is deferred to **2.x** — [roadmap.md](../roadmap.md) `schema-squash`.
Until then do not delete, rename, or reorder these files. Document the 1.x
rule in architecture § Persistence (one sentence).
Compat shims to keep until a major:
- `GET/POST /account` → 308 `/settings`.
- Domain rate-limit rows may still have an unused IP list column; enforcement
ignores it.
`sessions.username` is a string, not a `user_id` FK. Renames update the column;
a missed rename would orphan sessions. Acceptable; a FK would be a 1.x
migration if usernames become mutable in more places.
**Delete** `docs/plans/logrotate-mode.md` once this review is the active plan
(status `done`; history is git / CHANGELOG `[1.2.3]`).
---
## 10. Stubs and claimed-but-unimplemented behaviour
| Item | Status |
|---|---|
| Inbound relay | Agreed plan, **no code stubs**, no `INBOUND_RELAY_*` env. Correct. |
| DMARC report **ingestion** | Candidate. UI copy already promises “a future release will be able to receive reports in the panel”. Settings `rua=` and DNS guidance **are** implemented. |
| `panel-docs` | Candidate. Status blurbs were removed in 1.2.2 in favour of this item. |
| `CONTRIBUTING.md` | Candidate, file absent. Matches roadmap. |
| CSRF tokens | Explicitly not implemented (ADR). |
| `auth.RequireGlobal` | Dead helper, not a feature stub. |
The DMARC “future release” sentence is the only user-visible promise of
unimplemented behaviour. Soften it to “SelfPost does not receive inbound mail”
or keep it and treat `dmarc-reports` as the fulfilment — product call, Sonnet
copy.
---
## 11. GUI: hacks and layout composition
The panel is CSP-strict (no inline script/style; `TestNoTemplateUsesInlineScriptOrStyle`).
No `!important`. Progressive enhancement is real (pages work without JS).
Adaptive polling in `panel.js` is a **documented** workaround: HTMX
`hx-trigger="every Ns [expr]"` uses `new Function`, which CSP would break.
| Sev | Finding |
|---|---|
| **M** | `RateLimitErr` uses `class="flash error"`. `.flash` is the **success** surface; `.error` only recolors text. There is no `.flash.error` rule. Validation failures look like success (red text on green). `domain_detail.html` + `panel.css`. |
| **M** | User **Delete** has no `data-confirm` and no confirm page. App delete / regen / rate-limit clear do; domain delete has `domain_delete.html`. One mis-click removes a panel user. |
| **M** | `domain_detail.html` repeats Host/Type/Value/`code-row` for DNS status **and** publishable records. Extract a partial (same pattern as `encrypt_fields.html`). |
| **M** | `settings.html` duplicates the credentials form (global split vs domain-admin narrow card). Drift already visible in the muted help text. |
| **L** | Adaptive polling: `outerHTML` swap every 5 s can steal clicks / focus; poll failures retry silently. Consider `aria-live="polite"` and a visible retry/error. Do not switch back to `hx-trigger="every"` under this CSP. |
| **L** | Checkbox-driven Edit panels instead of `<details>` (commented in the template). Works without JS; no `aria-expanded`. |
| **L** | Five near-identical show/hide helpers in `panel.js`. Encrypt/import fields can flash visible before `DOMContentLoaded`. |
| **L** | `hx-get` query params in `deliveries_rows.html` are not `urlquery`-encoded. Safe while domain/app charset is locked down. |
| **L** | Applications on a domain page are unpaginated. Fine until an operator has dozens of apps. |
| **L** | `<label>` used as a heading on DNS/status readouts (no `for`). |
| **I** | `{{define "wide"}}` override and `main:has(> .card.narrow)` are non-obvious but tested. Keep; do not “simplify” into per-page CSS files. |
`data-confirm` is skipped when JS is off (documented in `panel.js` only).
Domain delete already uses a real page; user delete should follow that
pattern or at least get `data-confirm`.
---
## 12. Weakly documented disputed decisions
These are real choices. Several are in [security.md](../security.md); the
problem is **stale framing** after domain-admin, not silence.
| Decision | Where | Gap |
|---|---|---|
| CSRF via origin / `Sec-Fetch-Site`; no tokens; POST with neither header allowed | security.md ADR | Still argued as “single-user”. Revisit trigger already fired. **Rewrite the ADR**; implementing tokens is a separate product call. |
| Journal-milter fail-open | architecture, milter comments | Guide rate-limit section should say L2 is best-effort. |
| Unencrypted backup/export by default | security.md | OK. Domain-admin can **export working SASL passwords** for assigned domains (`HandleExportDomain` uses `lookupDomain`). Guide/security should say so. |
| Queue reconcile marks lost lines `bounced` | security.md | OK. |
| Sliding session, no absolute cap; HTMX GET does not renew | architecture, guide | OK. |
| Restore can resurrect sessions from an older backup | architecture | Missing from the operator guide. |
| L2 skipped when client IP is unknown | milter + unit test | Not in the guide. |
| Backup encryption optional | security.md | OK. |
| Supervisord socket `0770` so the panel can `postfix reload` | supervisord.conf | Compromised panel ≈ mail-stack control. Documented as intentional; keep. |
| `workflow_dispatch` on `release.yml` derives version from `GITHUB_REF_NAME` | `.github/workflows/release.yml` | A manual run from `main` can publish a non-semver tag. Guard: only `vX.Y.Z` or an explicit version input. |
---
## 13. Edge cases
Covered above: 0 / 1 / ≥2 assigned domains on the send log; forged
`domain`/`app` query params; empty allowlist must not mean “all”.
Others:
- **Last global administrator** cannot be demoted/deleted (UI + server). Good.
- **Domain-admin with no domains** (all assigned domains deleted →
`user_domains` cascade): today they see the full send log (P0). After the
fix they should see an empty log, not an error.
- **Missing `mail.log`** after rotation: treated as empty, not an error
(tested). Good.
- **Backup download after headers committed**: truncated file possible
(streaming trade-off). Encrypted domain export is sealed in memory first.
Acceptable; do not buffer full backups.
- **`parsePage`**: huge `p` yields a large offset and an empty page, not a
500. Fine.
- **Concurrent domain DNS on the dashboard**: safe under Go 1.22+.
- **Import domain** is global-only; **export** is any principal who can
access the domain. Intentional once documented.
---
## 14. Tests
**Strengths.** Milter L2 + in-flight, DNS grading, logtail follow/rotate/
reconcile, secretfile tamper, SASL argv hygiene (`--` before login), template
CSP/nav/legal footer, env-key ↔ guide regression, e2e mail path (AUTH, DKIM,
queued→sent, L1/L2, fail-open, hostname gate, session vs restart). Test
comments are better than average.
**Documented?** How to run tests: [development.md](../development.md) §
Testing. There is no e2e README (package comment in `test/e2e/main_test.go`
is the stand-in). Individual tests are not inventoried in docs — that is
fine; the e2e **summary** should mention logrotate.
**Gaps (high value):**
| Area | Gap |
|---|---|
| RBAC | **No** tests for `authz.go`, `CanAccessDomain`, domain-admin send-log scoping, `/users` 404 for domain-admin, backup 404. This is why P0 shipped. |
| Auth HTTP | No `HandleLogin` / `HandleSetup` tests (TTL, constant-time, setup complete → 404). No tests for `auth/ratelimit.go` `Allow`. |
| Sessions store | No `store/sessions*_test.go` (covered only via `auth_test` wrappers). |
| Backup as operator path | Create + `CheckRestore` unit-tested; **no** extract-onto-`/data`-and-boot test; panel `HandleBackup` POST untested. |
| Handlers | No tests for users CRUD, domain add/delete, account POST, DNS recheck endpoints. |
| `postfix.Queue` | Parser only; exec path untested (e2e does not open Mail queue). |
**Weak / low-value (keep, do not grow this style):**
- `TestDecryptErrorMessage` — substring mapping.
- `TestBackupPageOffersEncryption``strings.Contains` over HTML.
- Many `templates_test.go` cases — structural guards (CSP, nav). Valuable as
guards, not as behaviour tests.
- E2e `testNoAuthRejected` vs `testForeignRelayRejected` — nearly the same
unauthenticated send.
**E2e hygiene:**
- Fatal string in `testLevel1RateLimit` says `RATE_LIMIT_MESSAGES_PER_IP=5`;
override is `50` (`test/e2e/negative_test.go` vs `compose.override.yml`).
- `coredns/coredns:latest` is unpinned.
- `TestImageBuildPreservesLogrotateMode` chmods the source conf then rebuilds
— can race a dirty tree.
Do not add snapshot tests of entire pages. Add **authorization** tests that
would have caught P0.
---
## 15. Duplication and local patches
| Local patch | Systemic fix |
|---|---|
| Send-log domain/app allowlist after/around the query | Store filter: `Domains []string` required for non-global; validate app login first |
| `assignedDomains` loads all domains | Use `listUserDomainNames` / `ListDomainsForUser` |
| `requireGlobal` on each handler | Optional: `auth.RequireGlobal` on those muxes |
| Five JS field-sync helpers | One `data-show-when` helper |
| DNS host/type/value markup × many | Template partial |
| Settings credentials form × 2 | Partial |
| `web/validate` vs `app/validate` | Keep separate (different alphabets); do not merge |
The send-log allowlist is the textbook “local patch instead of a store
invariant”.
---
## 16. Other improvements
- Pin CoreDNS in e2e.
- Guard `release.yml` `workflow_dispatch` versioning (**Opus**, infra).
- Optional: `Resync` once after a successful `CheckRestore` (heal drifted
maps). Small, mail-path, **Opus**. Not required if the tarball is the
restore story — but then **fix the backup package comment**.
- Optional: serve `NOTICE` next to `/license`.
- Do not start inbound-relay until P0 is closed.
---
## Implementation checklist
Work top to bottom. Commit per phase (or per coherent sub-step) when asked.
Update [CHANGELOG.md](../../CHANGELOG.md) `[Unreleased]` with each user-visible
change. After Go changes: `go build`, `go vet`, `go test ./...`.
### P0 — Domain-admin send-log authorization (defect)
**Model: Opus.** Tests in the same change. **Fable** on the diff after it
lands (reviewer ≠ author).
- [x] Extend `SendLogFilter` so a non-empty domain list is an `IN` constraint.
Empty list for a non-global user → zero rows, not “all”. Done as
`Domains` + `AllDomains`: the zero value matches nothing, so a caller
that states no scope cannot read the journal.
- [x] `sendLogData`: for `!p.IsGlobal()`, always constrain to assigned domain
names; validate `AppLogin` against the users apps **before** query.
- [x] Tests: domain-admin with 0, 1, and 2 assigned domains; unfiltered list;
forged `?domain=` and `?app=`; detail page still 404s on a foreign id
(already true — keep a regression test).
- [x] Comment the invariant next to `sendLogData` (the comment that was
missing).
**Done when:** a domain-admin cannot read another domains send-log rows via
the list, the fragment, or query parameters. `go test ./...` green.
### P1 — Fail-closed consistency (mail path / auth)
**Model: Opus.**
- [x] `inflight.tryAdmit` (count + reserve under one lock). Extend milter
tests with overlapping `MailFrom` (true concurrency, not sequential).
Two tests: concurrent `MailFrom` sessions gated so they all read the
stored count before anyone reserves (exactly one admitted), and a
saturation test on `tryAdmit` that overshoots the ceiling whenever count
and reserve are separate critical sections.
- [x] `sessionStore.Create` returns an error; login does not set a cookie on
failure.
- [x] `app.Service.Delete`: SASL (and rate-limit row) before or compensating
with the registry row; match domain-delete ordering. Test the failure
path with a fake SASL that errors.
**Done when:** unit tests cover the race and the two fail-closed paths.
### P2 — Security/operator docs that are wrong today
**Model: Sonnet** (English docs). No code behaviour change except copy.
- [x] Rewrite the CSRF ADR in [security.md](../security.md) for a panel that
already has global + domain-admin. Keep the origin-check mechanism
unless a new decision says otherwise. New revisit trigger (e.g. untrusted
domain-admins, or a requirement that does not depend on browser
headers).
- [x] Drop “or argon2” unless argon2 is implemented.
- [x] [guide.md](../guide.md): Users / roles; Settings DMARC field; L2
fail-open; restore can resurrect sessions; domain-admin can export
working SASL passwords for assigned domains.
- [x] Architecture route table: `/license`, `/account``/settings`.
- [x] Fix `internal/backup` package comment (restore = extract tarball +
`CheckRestore`; maps come from the archive; Reload heals drift).
- [x] `admin.dmarc_report_email``settings` in roadmap + dmarc-reports plan.
- [x] development.md e2e summary: logrotate + process checks.
- [x] `setupManager` / `store` package comments: `users`, not `admin` row.
**Done when:** an operator who reads only the guide knows the panel has two
roles, and security.md no longer calls the panel single-user.
### P3 — GUI defects from the 1.2.x layout pass
**Model: Sonnet.**
- [x] `.flash.error` (or stop using `.flash` for `RateLimitErr`) — danger
surface, not success.
- [x] User delete: `data-confirm` at minimum; prefer a confirm page like
domain delete. Done as a confirm page (`GET/POST /users/{uid}/delete`),
matching `domain_delete.html`.
- [x] DNS field partial; settings credentials partial.
- [x] Optional: `urlquery` on deliveries fragment params; `aria-live` on
polled regions; confirm-without-JS note next to the CSRF accepted risks.
**Done when:** a rate-limit validation error is visually an error; user delete
cannot be a single unmarked click.
### P4 — Tests and e2e hygiene
**Model: Opus** for auth/RBAC/limiter tests; **Haiku** for the L1 fatal-string
typo; **Sonnet** if e2e docs need a paragraph.
- [x] `auth/ratelimit.go` unit tests (window, lockout, sweep). Also the
per-key scope: one locked-out address must not lock out the others.
- [x] Login/setup handler tests (happy path + lockout + setup expiry). The
lockout test also states that a correct password does not bypass it, and
that the two refusals are byte-identical (no username enumeration).
- [x] Domain-admin 404 on `/users`, `/backup`, `/mail-queue`, `/system-log`,
`/status` — as a table of every global-only route (`internal/web/handlers/authz_test.go`),
including the write routes, plus the same 404 for a request with no
principal and a positive control so the table cannot pass on a handler
that always 404s.
- [x] Fix e2e L1 fatal string (`50`, not `5`).
- [x] Pin `coredns` image: tag `1.14.6`, not a digest — the tag is a multi-arch
manifest and the stand has to come up on arm64 developer machines.
- [x] Optional: backup extract + `CheckRestore` + panel boot. Done as an
in-process integration test (`cmd/panel/restore_test.go`) rather than
e2e, so it runs in `go test ./...`: the archive is downloaded from a
running panel through `POST /backup` (which closes the “`HandleBackup`
POST untested” gap in §14 as well), unpacked the way `tar -xzf` unpacks
it, and a second panel is booted on the result through run()'s own
startup order. Also covers the encrypted download, the version-mismatch
refusal, that a restore does not reopen the setup link, and that sessions
travel in the archive. `serveHTTP` was split so the composition it
performs (`newPanel`) can be booted without binding a port.
**Done when:** P0 cannot regress without a red test; e2e L1 message matches
the override.
### P5 — Licence and release infra
**Model: Sonnet** for OFL/NOTICE prose; **Opus** for `release.yml`; **Haiku**
for SPDX consistency and deleting the done logrotate plan.
- [x] Add SIL OFL 1.1 text beside the Plex WOFF2 files; point `NOTICE` at it.
IBM Plex `LICENSE.txt` as `internal/web/view/static/OFL.txt` (copyright
+ OFL 1.1). Copied into the image at `/usr/share/doc/selfpost/OFL.txt`;
served at `/static/OFL.txt`.
- [x] `NOTICE` Source URL instructions → `internal/legal/legal.go`.
- [x] `release.yml`: `workflow_dispatch` must not publish `main` as a version
(require `vX.Y.Z` or an explicit `version` input that matches SemVer).
- [x] Delete [plans/logrotate-mode.md](logrotate-mode.md) (done; git keeps it).
- [x] Decide SPDX-everywhere vs SPDX-nowhere; do not leave two files special
without a one-line note in development.md. SPDX-nowhere: dropped the
two `cmd/` headers; development.md § External libraries records that
AGPL-3.0 does not require per-file SPDX.
**Done when:** OFL travels with the fonts; a dispatch from `main` cannot tag
`ghcr.io/...:main`.
### P6 — Optional cleanup (do not start until P0P3 are done)
**Model: Sonnet** unless noted.
- [x] Use `auth.RequireGlobal` or delete it (**Haiku** if delete). Deleted.
- [x] Rename `handlers_account.go` / `HandleAccount` to settings (**Haiku**).
- [x] `ListDomainsForUser` instead of load-all-and-filter.
- [x] Cap or periodically sweep the login limiter map (**Opus**, small).
- [x] Collapse `panel.js` field-sync helpers.
- [x] Soften or keep DMARC “future release” UI copy (product call). Softened.
- [ ] Optional startup `Resync` after restore (**Opus**). Dropped — P2 backup
comment fix is sufficient.
### P7 — Security review of the P0P1 diff
**Model: Fable.** Not authorship.
- [ ] Review the send-log authz change, `tryAdmit`, session create, and app
delete ordering against [security.md](../security.md). Close each finding
with a fix or an accepted-risk entry.
---
## What not to do
- Do not squash SQLite migrations in **1.x** (see roadmap `schema-squash` for 2.x).
- Do not implement inbound-relay, DMARC ingestion, or in-panel docs as part of
this plan.
- Do not add CSRF tokens in the same breath as rewriting the ADR. Tokens are a
new decision.
- Do not “simplify” comments that record threat models.
- Do not introduce a general service/repository layer for users.
---
## Done when (this plan)
1. P0 is shipped and covered by tests.
2. P2 has removed “single-user” from the CSRF ADR and documented Users in the
operator guide.
3. P3 flash/delete bugs are gone.
4. P7 has run on the P0P1 diff.
5. This files remaining boxes are either checked or explicitly dropped in
[roadmap.md](../roadmap.md) with a reason.
6. [CHANGELOG.md](../../CHANGELOG.md) `[Unreleased]` lists the user-visible
items (authz, docs, GUI).
After that, delete this plan (history in git) and return the recommended
order on the roadmap to inbound-relay.
+3 -2
View File
@@ -3,8 +3,9 @@
**Status:** agreed **Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once **Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance). the implementation lands (do not fix a major in advance).
**Order:** after [code-review.md](code-review.md) P0 (and preferably P0P3); **Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
this remains the largest remaining 1.x+ **feature**. The domain-admin role `[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
The domain-admin role
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md) and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
`[1.2.0]`. `[1.2.0]`.
+1 -2
View File
@@ -3,8 +3,7 @@
**Status:** agreed **Status:** agreed
**Date:** 2026-08-13 **Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface. **Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay. May follow or **Order:** small panel item; does not wait on inbound-relay.
interleave with remaining [code-review.md](code-review.md) work after P0.
--- ---
+6 -28
View File
@@ -28,7 +28,6 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Plan | | ID | Topic | Status | Plan |
|---|---|---|---| |---|---|---|---|
| code-review | Full-tree review follow-ups (authz, docs, GUI, tests) | **agreed** | [plans/code-review.md](plans/code-review.md) |
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) | | queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | | contributing | `CONTRIBUTING.md` | candidate | — |
@@ -36,39 +35,18 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-docs | In-panel operator documentation | candidate | — | | panel-docs | In-panel operator documentation | candidate | — |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — |
**Recommended order** (not binding): **code-review P0** first (shipped **Recommended order** (not binding): **queue-retries** is a small panel item
domain-admin send-log leak — a defect, not a feature), then the rest of that that can land first or in parallel; the next feature is **inbound-relay**.
plan as listed; **queue-retries** is a small panel item that can land in The 2026-08-13 full-tree review follow-ups (send-log authorization,
parallel after P0; then **inbound-relay**. Candidates need explicit agreement fail-closed paths, docs, GUI, tests, licence) are closed — history in
before they join the queue. [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need
explicit agreement before they join the queue.
After a context reset, pick an item marked `agreed` or `in progress`, then work After a context reset, pick an item marked `agreed` or `in progress`, then work
the checklist in its linked plan. the checklist in its linked plan.
--- ---
## code-review
**Goal:** close the 2026-08-13 full-tree review: domain-admin send-log
authorization, a few fail-closed paths, docs that still say “single-user”,
GUI flash/delete bugs, test gaps, licence/release hygiene.
**Boundary:** defects and docs/UI follow-ups inside the current 1.x product.
Not inbound-relay, not DMARC ingestion, not a layer rewrite.
**Done when:** see the criteria in
[plans/code-review.md](plans/code-review.md).
**Progress:** P0P5 done. Remaining: P6 (optional cleanup), P7 (Fable review
of the P0P1 diff).
**Dependencies / risks:** P0 is confidentiality between panel roles; it
jumps the feature queue. Implementation models are in the plan (Opus / Sonnet
/ Haiku / Fable per [development.md](development.md)).
**Version:** patch.
---
## queue-retries ## queue-retries
**Goal:** show on Mail queue and on a delivery's history how this Postfix **Goal:** show on Mail queue and on a delivery's history how this Postfix
+7 -2
View File
@@ -5,7 +5,12 @@ meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
in full: no exploitable findings; one defence-in-depth change — `--` before the in full: no exploitable findings; one defence-in-depth change — `--` before the
login in the `saslpasswd2` argv login in the `saslpasswd2` argv
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** ([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0P1 diff of the
2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. (2) **Accepted risks**
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -193,7 +198,7 @@ domain-admin's browser sending a request still needs that domain-admin's own
cookie, so a token would not add a boundary between roles that the cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`, authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route [internal/web/auth/principal.go](../internal/web/auth/principal.go); route
gating in [internal/web/auth/middleware.go](../internal/web/auth/middleware.go)) gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
don't already enforce. The remaining case is an external site making a don't already enforce. The remaining case is an external site making a
logged-in user's browser send a request, which the origin check covers without logged-in user's browser send a request, which the origin check covers without
touching a single template. touching a single template.
+21 -19
View File
@@ -9,15 +9,16 @@
// Restore is not a separate code path in the panel: a backup is extracted into // Restore is not a separate code path in the panel: a backup is extracted into
// the /data bind mount before first start, and the archive already carries // the /data bind mount before first start, and the archive already carries
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender // everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender
// map — so nothing needs to be regenerated from SQLite for the daemons to // map — so the daemons can start on the extracted files alone. The
// start correctly. The only restore-specific step the panel runs is // restore-specific steps the panel runs are CheckRestore, which refuses to
// CheckRestore, which refuses to boot if the manifest's version does not match // boot if the manifest's version does not match the running binary so
// the running binary, so schema/format skew between versions cannot silently // schema/format skew between versions cannot silently corrupt state
// corrupt state (architecture.md § Persistence). If a daemon's on-disk state // (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
// ever drifts from what SQLite records — for example after a manual edit // and the Postfix sender map from SQLite on that first boot, so any drift
// under /data — the Status page's "Reload configuration" button re-derives // between the archive and the database is healed before mail flows. If
// OpenDKIM's tables and the Postfix sender map from the database; that is a // on-disk state drifts again later — for example after a manual edit under
// deliberate, operator-triggered heal, not something restore does on its own. // /data — the Status page's "Reload configuration" button runs the same
// Resync on demand.
package backup package backup
import ( import (
@@ -269,33 +270,34 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
// directory), its version must match binaryVersion or the panel refuses to // directory), its version must match binaryVersion or the panel refuses to
// start, telling the operator which image tag to use. On a match the manifest // start, telling the operator which image tag to use. On a match the manifest
// is consumed (deleted) so it guards only the first boot after a restore and // is consumed (deleted) so it guards only the first boot after a restore and
// never blocks a later in-place image upgrade. Absence of the manifest is the // never blocks a later in-place image upgrade, and restored is true so the
// normal case and returns nil. // caller can heal drifted daemon maps once. Absence of the manifest is the
func CheckRestore(manifestPath, binaryVersion string) error { // normal case and returns restored == false with a nil error.
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
data, err := os.ReadFile(manifestPath) data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil // ordinary start, not a restore return false, nil // ordinary start, not a restore
} }
if err != nil { if err != nil {
return fmt.Errorf("backup: read restore manifest: %w", err) return false, fmt.Errorf("backup: read restore manifest: %w", err)
} }
var m Manifest var m Manifest
if err := json.Unmarshal(data, &m); err != nil { if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err) return false, fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
} }
if m.Format != FormatFull { if m.Format != FormatFull {
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format) return false, fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
} }
if m.Version != binaryVersion { if m.Version != binaryVersion {
return fmt.Errorf( return false, fmt.Errorf(
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)", "backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
m.Version, binaryVersion, m.Version) m.Version, binaryVersion, m.Version)
} }
// Version matches: consume the manifest so subsequent normal starts (and // Version matches: consume the manifest so subsequent normal starts (and
// in-place upgrades) are not gated by it. // in-place upgrades) are not gated by it.
if err := os.Remove(manifestPath); err != nil { if err := os.Remove(manifestPath); err != nil {
return fmt.Errorf("backup: consume restore manifest: %w", err) return false, fmt.Errorf("backup: consume restore manifest: %w", err)
} }
return nil return true, nil
} }
+19 -4
View File
@@ -156,17 +156,25 @@ func writeManifest(t *testing.T, dir, format, version string) string {
} }
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) { func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil { restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0")
if err != nil {
t.Errorf("CheckRestore with no manifest = %v, want nil", err) t.Errorf("CheckRestore with no manifest = %v, want nil", err)
} }
if restored {
t.Error("CheckRestore with no manifest reported a restore")
}
} }
func TestCheckRestoreMatchConsumesManifest(t *testing.T) { func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0") path := writeManifest(t, dir, FormatFull, "1.0.0")
if err := CheckRestore(path, "1.0.0"); err != nil { restored, err := CheckRestore(path, "1.0.0")
if err != nil {
t.Fatalf("CheckRestore matching = %v, want nil", err) t.Fatalf("CheckRestore matching = %v, want nil", err)
} }
if !restored {
t.Fatal("CheckRestore matching did not report a restore")
}
if _, err := os.Stat(path); !os.IsNotExist(err) { if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err) t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
} }
@@ -175,10 +183,13 @@ func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) { func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0") path := writeManifest(t, dir, FormatFull, "1.0.0")
err := CheckRestore(path, "2.0.0") restored, err := CheckRestore(path, "2.0.0")
if err == nil { if err == nil {
t.Fatal("CheckRestore mismatch = nil, want error") t.Fatal("CheckRestore mismatch = nil, want error")
} }
if restored {
t.Error("CheckRestore mismatch reported a restore")
}
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") { if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
t.Errorf("error should name both versions: %v", err) t.Errorf("error should name both versions: %v", err)
} }
@@ -190,7 +201,11 @@ func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
func TestCheckRestoreWrongFormatRejected(t *testing.T) { func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := writeManifest(t, dir, "something-else", "1.0.0") path := writeManifest(t, dir, "something-else", "1.0.0")
if err := CheckRestore(path, "1.0.0"); err == nil { restored, err := CheckRestore(path, "1.0.0")
if err == nil {
t.Error("CheckRestore accepted a non-backup manifest") t.Error("CheckRestore accepted a non-backup manifest")
} }
if restored {
t.Error("CheckRestore wrong format reported a restore")
}
} }
+8
View File
@@ -167,6 +167,14 @@ func (o *OpenDKIM) Reload() error {
return o.reload() return o.reload()
} }
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (o *OpenDKIM) SetReloadHook(fn func() error) {
if fn != nil {
o.reload = fn
}
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain // renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is // set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written // re-checked for shell/config-injection safety before being written
+8
View File
@@ -75,6 +75,14 @@ func (p *Postfix) Reload() error {
return p.reload() return p.reload()
} }
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (p *Postfix) SetReloadHook(fn func() error) {
if fn != nil {
p.reload = fn
}
}
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are // renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
// sorted for deterministic output and the logins under each key are sorted and // sorted for deterministic output and the logins under each key are sorted and
// de-duplicated. Every address and login is re-checked for injection safety // de-duplicated. Every address and login is re-checked for injection safety