Compare commits

34 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
mix 02afa0fa80 fix(panel): code-review P6 optional cleanup
test / test (push) Has been cancelled
Close the remaining low-risk items from the full-tree review: rename the settings handler, query assigned domains in SQL, bound the login limiter map, collapse panel.js show/hide helpers, and soften DMARC copy that promised a future in-panel receiver.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 14:48:43 +03:00
mix 97a0c7e508 licence,ci: ship OFL with Plex fonts and guard release dispatch (P5)
test / test (push) Has been cancelled
IBM Plex WOFF2 files were shipped without the SIL OFL 1.1 text, NOTICE
told modifiers to edit layout.html for a Source URL that lives in
legal.go, and workflow_dispatch took GITHUB_REF_NAME as the version so a
run from main would publish ghcr.io/...:main.

Closes P5 of docs/plans/code-review.md. CHANGELOG updated.

Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:47:33 +03:00
mix efdc2d63fb docs: agree queue-retries (Postfix retry policy in the panel)
test / test (push) Has been cancelled
Roadmap item and plan: show first retry delay, backoff cap and queue
lifetime on Mail queue and the delivery page, reading postconf -h once
at panel start so a manual override is visible. Explanation only; not
implemented yet. CHANGELOG updated.

Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:51:13 +03:00
mix 8dfb483244 test: boot a panel on a data directory restored from its own backup (P4)
test / test (push) Has been cancelled
Restore is not a code path in the panel — the operator extracts the archive
onto /data and starts the image — so it had no test. cmd/panel/restore_test.go
now performs that path in process: download a backup from a running panel
through POST /backup (plain and encrypted), unpack it the way tar -xzf does,
and boot a second panel on the result through run()'s own startup order
(CheckRestore, store.Open, newPanel, Start).

Covered: the restored panel shows the domain and journal the archive carried
and finds the DKIM key, sasldb2 and Postfix sender map where its configuration
says they are; the setup link is not reopened by a restore; a session that
predates the backup still works, as the guide documents; an encrypted download
restores identically; a data directory from another version is refused with
both versions named and the manifest kept for the retry.

serveHTTP is split so the composition it performs (newPanel) can be started
without binding a port. No behaviour change.

Closes the optional P4 item in docs/plans/code-review.md, and with it the
"HandleBackup POST untested" gap from the review's test section. CHANGELOG
updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:46:01 +03:00
mix e6af1a7866 test: cover the auth, sign-in and RBAC surfaces P0 shipped through (P4)
test / test (push) Has been cancelled
- internal/web/auth/ratelimit_test.go: the login/setup limiter's ceiling,
  per-address scope, window reset and the sweep that keeps finished
  buckets out of memory.
- internal/web/auth/handlers_test.go: sign-in (session issued, refusals
  that do not reveal which usernames exist, a lockout a correct password
  cannot bypass, the setup hint before the first administrator) and the
  one-time setup link (creates the first global administrator and then
  closes, rejects a wrong or expired token, refuses credentials the panel
  would not accept later, rate-limited).
- internal/web/handlers/authz_test.go: every global-only route answers a
  domain administrator — and a request with no principal — with 404, with
  a positive control so the table cannot pass on a handler that always
  404s. This is the check that would have caught the send-log leak.
- test/e2e: CoreDNS pinned to 1.14.6 instead of latest; the level-1
  failure message quoted RATE_LIMIT_MESSAGES_PER_IP=5 while the stand
  sets 50.

docs/plans/code-review.md P4 checked off except the optional backup-boot
test; CHANGELOG updated. No production code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:35:46 +03:00
mix aab35f0679 fix(panel): P3 optional items — urlquery, aria-live, confirm-without-JS note
- Deliveries fragment/pagination links urlquery-encode the domain/app
  filters instead of splicing them into the query string raw.
- The four polled regions (deliveries rows, status, mail queue, system
  log) carry aria-live="polite" so a screen reader announces refreshes.
- security.md accepted risks note that data-confirm prompts are
  JavaScript-only and why that is acceptable.

docs/plans/code-review.md P3 fully checked off; CHANGELOG updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:54:31 +03:00
mix 0570608738 fix(panel): GUI defects from the 1.2.x layout pass (P3)
- .flash.error now renders on the danger surface instead of the success
  one; RateLimitErr previously showed as green with red text.
- User delete goes through a confirmation page (GET/POST
  /users/{uid}/delete), matching the domain-delete pattern, instead of a
  plain submit button next to Save with no confirmation.
- Extracted the repeated DNS Host/Type/Value markup on a domain's page and
  the duplicated Settings credentials form into shared partials. No
  behaviour change.

docs/plans/code-review.md P3 checked off; CHANGELOG updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:50:42 +03:00
mix 1cf8bfcbe2 docs: rewrite security/operator docs for the multi-user panel (P2)
test / test (push) Has been cancelled
The CSRF ADR, guide.md, architecture.md route table, the internal/backup
package comment, roadmap/dmarc-reports admin.dmarc_report_email references,
the e2e coverage summary, and the setupManager/store package comments still
described the pre-1.2.0 single-administrator panel or stale implementation
details. Closes P2 of docs/plans/code-review.md; no behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 16:15:14 +03:00
mix 4761991dd5 panel,mail: fail closed on the rate-limit race, session create and app delete
test / test (push) Has been cancelled
The level-2 limiter counted stored plus in-flight messages and reserved its own slot in two critical sections, so SMTP sessions that overlapped could each take the last free slot; tryAdmit now does both under one lock. A session that cannot be written no longer yields a cookie the browser would carry while every request bounced to /login. Deleting an application clears its SASL account before its registry row, matching domain delete, so a saslpasswd2 failure leaves a retryable application rather than an account that still authenticates.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:50:04 +03:00
mix 8355479e03 docs: note e2e send-log badge scraper fix in Unreleased
test / test (push) Has been cancelled
The release gate timed out waiting for status=sent because its HTML scrapers had drifted from the panel's badge markup; record the fix and the handler regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:36:11 +03:00
mix 6216e430e9 test(e2e): follow send-log status badge markup in scrapers
test / test (push) Has been cancelled
The deliveries_rows template wraps status in badge spans, but the e2e gate still looked for bare td cells, so send_verify_dkim_and_status timed out even when mail was delivered and logged. Add a handler regression test so go test ./... catches this drift without Docker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:19:31 +03:00
mix a1e7e05d26 ci: gofmt files that failed the formatting workflow check
test / test (push) Has been cancelled
Whitespace-only alignment from gofmt; removes a stray BOM in web.go.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:24:34 +03:00
mix fae0068c4a panel: scope the send log to a domain admin's own domains
test / test (push) Has been cancelled
The Deliveries list narrowed the journal only when exactly one domain was assigned, so an administrator with none or with two or more read every domain's rows. The domain scope is now an IN constraint the store query carries, a filter that states no scope matches nothing, and the domain/app query parameters are validated against the principal before the query runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:20:51 +03:00
mix b829625066 docs: record full-tree review and 2.x schema squash
test / test (push) Has been cancelled
Capture the 2026-08-13 review as the active plan, queue it ahead of inbound-relay, and defer SQLite migration squash to 2.x with a user_version gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:36:08 +03:00
mix 71048922b9 release: 1.2.5
test / test (push) Has been cancelled
Close Unreleased for the rate-limit form polish; pin compose and docs to 1.2.5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:04:19 +03:00
mix 5caca024c3 panel: restore natural Export and Danger card layout
test / test (push) Has been cancelled
Drop the shared-baseline flex pin on .split cards; mismatched body
lengths made Export / Danger look worse than the plain stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:02:15 +03:00
mix 8289ed3916 panel: align paired domain settings and export actions
test / test (push) Has been cancelled
Subgrid Domain settings rows, match Address mode and trusted-IP controls,
and pin Export/Danger buttons to a shared baseline in split cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:39:55 +03:00
75 changed files with 3658 additions and 1053 deletions
+63 -13
View File
@@ -1,11 +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 — only a pushed tag matching # Ordinary commits and bare git tag pushes do not publish anything. A published
# vX.Y.Z does. That tag is the single source the version comes from: it goes # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# into both the image tag and the panel binary's -ldflags version, so the two # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
# 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
@@ -13,14 +11,18 @@ 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:
version:
description: "Image version as X.Y.Z (no v prefix)."
required: true
type: string
permissions: permissions:
contents: read contents: read
@@ -32,9 +34,25 @@ jobs:
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
steps: steps:
- name: Derive version from tag - name: Derive a SemVer X.Y.Z version
id: version id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" env:
INPUT_VERSION: ${{ github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "release" ]; then
raw="${RELEASE_TAG:-}"
else
raw="${INPUT_VERSION:-}"
fi
raw="${raw#v}"
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
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
fi
echo "version=${raw}" >> "$GITHUB_OUTPUT"
build: build:
needs: prepare needs: prepare
@@ -49,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
@@ -113,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
@@ -152,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
+242 -6
View File
@@ -5,14 +5,250 @@ 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
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
WOFF2 files (`internal/web/view/static/OFL.txt`). The image copies it next
to LICENSE and NOTICE under `/usr/share/doc/selfpost/`; the panel serves it
at `/static/OFL.txt`. OFL requires the licence to accompany the font.
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
delivery's history, reading `postconf -h` once at panel start so a manual
override is visible. Plan: [docs/plans/queue-retries.md](docs/plans/queue-retries.md).
Explanation only; no attempt counter and no panel knobs. Not yet
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
- test (e2e): send-log status scrapers follow the badge markup in
`deliveries_rows`. The release gate still looked for bare `<td>sent</td>`
after the panel started rendering status as `<span class="st st-*">` badges,
so `send_verify_dkim_and_status` timed out even when mail was delivered and
logged. A handler regression test catches this drift in `go test ./...`
without Docker.
- security (panel): the Deliveries list is scoped to a domain administrator's
assigned domains for every number of assignments. Previously the send log was
narrowed only when exactly one domain was assigned, so an administrator with
none or with two or more read every domain's rows (sender, recipient, subject)
on the list and its polled fragment. The domain scope is now an `IN`
constraint carried by the store query — a filter that states no scope returns
nothing — and the `domain` and `app` query parameters are checked against the
principal's own domains and applications before the query runs, so a
hand-written URL cannot widen the scope. Global administrators are unaffected.
- mail (level-2 rate limit): the ceiling is no longer overshot by messages that
arrive at the same instant. The milter counted the stored and in-flight
messages and reserved its own slot in two separate steps, so several SMTP
sessions could pass the same check before any of them had reserved. Counting
and reserving now happen as one operation, and the ceiling is handed out
exactly as many times as configured. Postfix's level-1 limit remains the
backstop and the level-2 check stays fail-open on store errors.
- panel (sign-in): a session that cannot be written to the database no longer
produces a session cookie. The login used to log the failure, set the cookie
and redirect to the dashboard, leaving the browser looking signed in while
every request bounced back to `/login`; it now fails closed with an error on
the sign-in page.
- panel (applications): deleting an application removes its SASL credentials
before its registry row. If `saslpasswd2` fails, the application stays listed
and the delete can be retried, instead of leaving a hidden account that could
still authenticate to Postfix. This matches the order domain deletion already
used.
- panel (GUI): a rejected rate-limit change on a domain's page now renders on
the danger surface (`.flash.error`) instead of the success one — it was
green with red text, reading as good news. Deleting a panel user now goes
through a confirmation page, the same pattern as domain deletion, instead of
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
- panel: level-1 backstop is shown as muted copy on the domain form - docs: operator and as-built docs aligned with the code after a full
(`N messages / Ws — Settings`) and in message-limit labels; Settings uses a pass — [architecture.md](docs/architecture.md) route table now marks
code-row for the L1 value. Domain and application rate-limit status uses the **global** routes (404 for domain administrators) and documents the
shared `st` badge. Trusted-IP help sits under the IP field; domain rate-limit one-time restore Resync in Persistence; session/password and restore-session
help sits above Save; Address mode drops the duplicate field label; field-pair wording corrected in [guide.md](docs/guide.md) and architecture (own-password
top spacing aligns with paired DMARC labels. 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
`internal/legal/legal.go` (the value the panel footer actually injects), not
`layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
packages were 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
[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.
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
memory; sign-in for a successful session, for refusals that do not reveal
which usernames exist, and for a lockout that a correct password cannot
bypass; the one-time setup link for creating the first administrator, closing
afterwards, rejecting a wrong or expired token, and refusing credentials the
panel would not accept later. Every global-only route (`/users`, `/backup`,
domain import, `/status`, `/mail-queue`, `/system-log`, domain add and delete,
reload) is checked to answer a domain administrator — and a request with no
principal — with 404, the check that would have caught the send-log leak.
- test: restore is covered as the operator performs it, in process. A backup is
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
second panel is booted on the result through the startup order the panel
itself uses — version guard, database, one Resync when restoring, then
services and the HTTP application. The restored panel shows the domain and
journal the archive carried, finds the DKIM key, SASL database and Postfix
sender map where its configuration says they are, does not reopen the
one-time setup link, and still honours a session that predates the backup.
Drifted on-disk maps are healed by that Resync step
(`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.
- test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so
the release gate cannot change under a commit between two runs. The level-1
rate-limit failure message quoted `RATE_LIMIT_MESSAGES_PER_IP=5` while the
stand sets `50`.
- ci: gofmt on eight files that failed the formatting workflow check (panel
config, DNS check, domain transfer export, rate-limit tests, auth principal,
domain and delivery handlers, web package doc comment).
- panel (templates): the repeated Host/Type/Value DNS record markup on a
domain's page and the duplicated credentials form on Settings are now
shared partials (`host_type`, `host_type_copy`, `field_value`,
`field_values`, `credentials_fields`) instead of copy-pasted blocks. No
behaviour or visible change.
- panel (GUI, accessibility): the Deliveries fragment's `hx-get` and pagination
links now `urlquery`-encode the `domain`/`app` filters instead of splicing
them into the query string raw. The four polled regions (deliveries rows,
status, mail queue, system log) carry `aria-live="polite"` so a screen
reader announces the refreshed content.
- docs: [security.md](docs/security.md) accepted risks now note that
`data-confirm` prompts on destructive forms are JavaScript-only — with
JavaScript disabled the form submits immediately, the same as before the
prompts existed — and why that is acceptable (the prompt is a mis-click
guard, not an authorization boundary).
- 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
[security.md](docs/security.md) no longer argues from "single-user"; it now
states that cross-user CSRF between panel roles is not the threat the origin
check defends against, and gives a new revisit trigger. Dropped the
unimplemented "or argon2" alternative for the password hash.
[guide.md](docs/guide.md) documents the Users page and the two roles,
level-2 rate limiting's fail-open behaviour, and that a domain-admin can
export working SASL passwords for domains assigned to them.
[architecture.md](docs/architecture.md) gains `/license` and the
`/account``/settings` redirect in the route table (later expanded for
RBAC in the doc-alignment pass above). Corrected stale
`admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
actual home after migration `0005`. No behaviour change.
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
gone (handlers already call `requireGlobal`); the settings route handler is
named `HandleSettings` in `handlers_settings.go`; domain lists for a
domain-admin now come from `ListDomainsForUser` in SQL instead of loading
every domain and filtering in Go; the login and setup rate limiters sweep
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
longer promises in-panel report reception in a future release — SelfPost
does not receive inbound mail.
## [1.2.5] - 2026-08-13
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
### Changed
- panel: rate-limit forms refined — level-1 backstop as muted copy on the
domain settings form (`N messages / Ws — Settings`) and in message-limit
labels (`max N`); Settings shows the L1 ceiling in a code-row. Domain,
application, and Edit-toggle limit state use the shared `st` badge instead of
bold text or parenthetical copy. Address mode and trusted-IP override columns
carry muted leads and matched control height; trusted-IP help sits under the
IP field. Domain settings pairs DMARC reports with the level-2 rate limit
using CSS subgrid so titles, fields, and Save / Remove buttons line up across
columns.
## [1.2.4] - 2026-08-12 ## [1.2.4] - 2026-08-12
+5 -2
View File
@@ -10,8 +10,8 @@ Corresponding Source for the published releases is at:
If you modify SelfPost and let users interact with it over a network, If you modify SelfPost and let users interact with it over a network,
AGPL-3.0 §13 requires you to offer them the Corresponding Source of AGPL-3.0 §13 requires you to offer them the Corresponding Source of
your modified version. Update the Source link in the panel footer your modified version. Update SourceURL in internal/legal/legal.go
(internal/web/view/templates/layout.html) so it points at your sources. so the panel footer points at your sources.
Third-party software included in this distribution Third-party software included in this distribution
---------------------------------------------------- ----------------------------------------------------
@@ -24,6 +24,9 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
IBM Plex Sans / IBM Plex Mono IBM Plex Sans / IBM Plex Mono
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex" Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
Licence: SIL Open Font License 1.1 Licence: SIL Open Font License 1.1
Full text: internal/web/view/static/OFL.txt
(also /usr/share/doc/selfpost/OFL.txt in the image, and
/static/OFL.txt from the control panel)
https://github.com/IBM/plex https://github.com/IBM/plex
Distributed unmodified as three latin-subset WOFF2 files served by the Distributed unmodified as three latin-subset WOFF2 files served by the
control panel: control panel:
+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.4 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
+6 -2
View File
@@ -87,12 +87,16 @@ COPY --from=build /out/panel /usr/local/bin/panel
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also # Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
# serves the same text at /license from an embedded copy. # serves the same text at /license from an embedded copy. OFL.txt is the SIL
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
# embedded in the panel binary (NOTICE).
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/ COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
# File modes are pinned here so a build context copied from a checkout without # File modes are pinned here so a build context copied from a checkout without
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config # POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
# that logrotate would silently ignore — see docs/plans/logrotate-mode.md. # that logrotate would silently ignore — see docs/development.md § Building
# binaries and the image.
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
+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",
+58 -10
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"
) )
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using // mailStack is the panel's domain and application services plus the on-disk
// the database handle shared by all roles: setup, login and the authenticated // mail-path adapters they write through.
// panel surface (security.md). type mailStack struct {
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { Domains *domain.Service
// Applications own the SASL accounts and the Postfix sender map; the domain Apps *app.Service
// service delegates to them when a domain (and its applications) is deleted. pf *postfix.Postfix
pf := postfix.New(cfg.postfixDir) odk *domain.OpenDKIM
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) }
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
srvApp, err := web.New(st, domains, apps, web.Config{ 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
// 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.
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st)
return web.New(st, ms.Domains, ms.Apps, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled, SubmissionEnabled: cfg.submissionEnabled,
@@ -42,6 +83,13 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
}
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
// the database handle shared by all roles: setup, login and the authenticated
// panel surface (security.md).
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
srvApp, err := newPanel(cfg, st)
if err != nil { if err != nil {
return err return err
} }
+9 -2
View File
@@ -4,7 +4,6 @@
// the rate-limit checks. // the rate-limit checks.
// //
// Copyright (C) 2026 Mikhail Yenuchenko // Copyright (C) 2026 Mikhail Yenuchenko
// SPDX-License-Identifier: AGPL-3.0-only
package main package main
import ( import (
@@ -223,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
} }
@@ -236,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)
+456
View File
@@ -0,0 +1,456 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into the /data bind mount and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
// is started on the result through the same startup sequence run() uses:
// CheckRestore, store.Open, newPanel, Start.
const (
restorePassword = "correct-horse-battery"
restoreDomain = "bs.example.ru"
restoreSubject = "Order confirmation"
)
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
// has been in use, sign in, download a backup from it, extract that archive
// into an empty directory and boot a second panel there. A non-empty password
// takes the encrypted download and decrypts it on the way in, which is what an
// operator does with a .spbk file.
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelData(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
target := t.TempDir()
extract(t, archive, target)
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
}
// The panel has to come up on the restored directory and show the state that
// was in the archive, without the operator touching anything else: the domain
// and its journal are in the database the archive carried, and the credentials
// that worked before the restore still work after it.
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
}
}
// The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} {
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil {
t.Errorf("the restored data directory has no %s: %v", path, err)
continue
}
if string(got) != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
// restored database has one. A restore that reopened it would publish a link
// that creates a second global administrator on a server holding live mail
// credentials (security.md).
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
r := restoreFromOwnBackup(t, "")
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
}
body := getPage(t, r.panel, "/login", nil)
if strings.Contains(body, "No administrator has been created yet") {
t.Errorf("the restored panel offers first-run setup:\n%s", body)
}
}
// Sessions live in the database, so they travel in the archive: a cookie that
// was valid when the backup was taken is valid again on the restored panel.
// That is the documented consequence of restoring an older backup (guide §
// Backup and restore) — stated here so it cannot change by accident.
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
}
// An encrypted download is the same archive inside an envelope, so it restores
// the same way once the password is supplied. The archive is never written to
// disk in the clear by the panel, so this is the only place the two paths can
// be shown to agree.
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
}
// 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
// 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
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t)
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: live,
DBPath: filepath.Join(live, "selfpost.db"),
Version: "9.9.9",
}); err != nil {
t.Fatalf("create backup: %v", err)
}
target := t.TempDir()
extract(t, archive.Bytes(), target)
cfg := panelConfig(t, target)
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err == nil {
t.Fatal("the panel booted on a data directory left by another version")
}
for _, want := range []string{"9.9.9", buildinfo.Version} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
}
}
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
}
}
// seedPanelData builds the /data tree of a panel that has been in use: an
// administrator, a sending domain with an application and one logged message,
// and the daemon state the mail path needs (a DKIM key, the SASL database and
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
dom, err := st.AddDomain(restoreDomain, "selfpost")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application: %v", err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
}); err != nil {
t.Fatalf("insert send-log row: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} {
full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", full, err)
}
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", full, err)
}
}
return dataDir
}
// panelConfig resolves the panel's own configuration for a data directory, so
// the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config {
t.Helper()
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
// directory; without this the panel would read the host's /data.
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
return loadConfig()
}
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler {
t.Helper()
cfg := panelConfig(t, dataDir)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
}
if restored {
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")
}
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open the restored database: %v", err)
}
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)
if err != nil {
t.Fatalf("build the panel: %v", err)
}
if err := panel.Start(); err != nil {
t.Fatalf("start the panel: %v", err)
}
return panel.Handler()
}
// signIn signs in as the seeded administrator and returns the session cookie.
func signIn(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("sign in issued no session cookie")
}
return cookies[0]
}
// downloadBackup takes a backup through the panel's own /backup route, the way
// the operator does. An empty password downloads the plain archive; otherwise
// the response is a .spbk envelope, which is decrypted here.
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
t.Helper()
form := url.Values{}
if password != "" {
form.Set("encrypt", "1")
form.Set("password", password)
form.Set("password_confirm", password)
}
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
}
body := rec.Body.Bytes()
if password == "" {
if secretfile.HasMagic(body) {
t.Fatal("an unencrypted download came back as an envelope")
}
return body
}
if !secretfile.HasMagic(body) {
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
}
r, err := secretfile.NewReader(bytes.NewReader(body), password)
if err != nil {
t.Fatalf("open the encrypted backup: %v", err)
}
plain, err := io.ReadAll(r)
if err != nil {
t.Fatalf("decrypt the backup: %v", err)
}
return plain
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
// bind mount before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("the download is not a gzip stream: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
name := filepath.Clean(filepath.FromSlash(hdr.Name))
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
}
path := filepath.Join(dir, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := f.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
}
}
}
// getPage performs a GET and returns the body, failing on any non-200.
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
t.Helper()
rec := request(t, h, http.MethodGet, target, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// request drives the panel's real handler chain, including the origin check,
// with the headers a browser on the panel's own page would send.
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
req.Host = "mail.example.ru"
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
}
if session != nil {
req.AddCookie(session)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
-1
View File
@@ -18,7 +18,6 @@
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz // docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
// //
// Copyright (C) 2026 Mikhail Yenuchenko // Copyright (C) 2026 Mikhail Yenuchenko
// SPDX-License-Identifier: AGPL-3.0-only
package main package main
import ( import (
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.2.4 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}"
+61 -21
View File
@@ -154,26 +154,31 @@ 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`.
| Route | Purpose | | Route | Purpose |
|---|---| |---|---|
| `/healthz` | Liveness (no auth) | | `/healthz` | Liveness (no auth) |
| `/license` | Embedded `LICENSE` text (no auth) |
| `/setup/*` | One-time admin bootstrap | | `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth | | `/login`, `/logout` | Session auth |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains/import` | Domain import (`POST`; form on the Backup page) | | `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/deliveries` | Send log with filters | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/mail-queue` | Postfix queue view | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/system-log` | `mail.log` tail | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/backup` | Full backup download (page also hosts the import form) | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/settings` | Admin username/password and DMARC report address | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/users`, `/users/*` | Panel user CRUD (global admin only) | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on 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
@@ -192,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
[handlers_account.go](../internal/web/handlers/handlers_account.go)). ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
Restoring an **older** backup also restores session rows: a session invalidated 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.
--- ---
@@ -293,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 →
@@ -317,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`).
+71 -14
View File
@@ -93,8 +93,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
## External libraries ## External libraries
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
third-party notices: [NOTICE](../NOTICE). New Go dependencies must be third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
permissive or GPL-family (see `SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go
dependencies must be permissive or GPL-family (see
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
### Main module (`go.mod`) ### Main module (`go.mod`)
@@ -113,6 +114,7 @@ the tree are AGPL-3.0-compatible.
| Asset | Version | Repository | License | | Asset | Version | Repository | License |
|---|---|---|---| |---|---|---|---|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD | | `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
### E2e module (`test/e2e/go.mod`) ### E2e module (`test/e2e/go.mod`)
@@ -128,8 +130,10 @@ the image.
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian bookworm repositories; licenses are in each package's `copyright` from Debian bookworm repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/bookworm/>. file on <https://packages.debian.org/bookworm/>.
The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`. Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
the OFL text at `/static/OFL.txt`.
--- ---
@@ -187,9 +191,12 @@ tag / push only on explicit request (see `release.yml`).
### Release image ### Release image
The release image is published **only on tag** `vX.Y.Z` (not on every push to The release image is published **only** for a SemVer version `X.Y.Z`: a
`main`). The tag is the single source of version: it drives the image tag and **published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
`-ldflags` in the binaries so they cannot drift apart. that supplies that version. Pushing a git tag alone does not publish. Ordinary
commits, and a dispatch from `main` without a version input, do not publish.
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):**
@@ -197,8 +204,42 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to
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
@@ -265,7 +306,11 @@ operator would actually use.
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify → **Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch, send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
`docker restart`). Polling with timeouts only — no fixed `sleep`. `docker restart`); startup checks that supervisord actually brought up
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
Polling with timeouts only — no fixed `sleep`.
Requires **Docker + Compose v2** on the machine running the suite. Requires **Docker + Compose v2** on the machine running the suite.
@@ -280,21 +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` ### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
same pattern as gosentry / imap-scrub). You can also run it manually via
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
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) 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 tag) → 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.
+528 -243
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,55 +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
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
- **Settings** (`/settings`) — change the administrator username and/or password.
Application SASL logins are separate and are not changed here.
**Sessions.** A login survives a container restart: sessions live in SQLite, not ### Settings
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
`/settings` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
are separate and are not changed here.
### 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.
- **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
reachable (404). A domain-admin can *export* the
domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
The panel refuses to remove or demote the **last** global administrator, so
it can never end up with none.
### Sessions
A login survives a container restart: sessions live in SQLite, not in
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
seven days) with no absolute lifetime cap — an admin who keeps using the panel 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`
@@ -283,64 +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
```
## Backup, restore, and moving a single domain **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. 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)).
Two related but distinct operations **Restoring in place** (same host — recovering from data loss, or rolling
([architecture.md](architecture.md) § Persistence): back after a bad change):
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, ```sh
all applications' SASL credentials, `manifest.json` with the version that # 1. Stop the instance being replaced
created it): panel button (*Backup* → *Full backup*), or from the docker compose down
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.
**Alternative: archive `./data` while stopped.** If the service can be taken # 2. Move the current /data aside rather than deleting it, start from empty
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on mv ./data ./data.before-restore
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup mkdir ./data
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 # 3. Unpack the backup into the fresh directory
file, *Backup**Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working** tar xzf selfpost-backup.tar.gz -C ./data
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, this works across different hostnames/instances.
Both files are **secrets** — they contain the admin password hash (full # 4. docker-compose.yml must already pin the exact tag the backup was made
backup) or working application credentials (domain export) in the clear or in # with — check the archive's manifest if unsure:
directly reversible form. Treat them like any other credential material: tar xzf selfpost-backup.tar.gz -O manifest.json
restrict who can read them, don't email them around — and encrypt them, which
SelfPost can do for you.
### Encrypting a backup or export # 5. Start it and watch the boot
docker compose up -d
docker compose logs -f selfpost
```
A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.:
```
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:
@@ -380,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.4`. 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).
+2 -1
View File
@@ -18,7 +18,8 @@ operator.
- gzip + XML aggregate parsing → SQLite summaries per sending domain. - gzip + XML aggregate parsing → SQLite summaries per sending domain.
- Panel page and/or per-domain section: recent reports, third-party senders, - Panel page and/or per-domain section: recent reports, third-party senders,
delivery health of report ingestion. delivery health of report ingestion.
- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates; - Reuse the `dmarc_report_email` setting (moved off the old `admin` table into
`settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates;
when enabled, suggest a SelfPost-hosted report address. when enabled, suggest a SelfPost-hosted report address.
**Out:** **Out:**
+5 -3
View File
@@ -3,9 +3,11 @@
**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:** recommended next among agreed 1.x+ items (after `internal/web` split **Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md) `[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
`[1.2.0]`). The domain-admin role
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
`[1.2.0]`.
--- ---
-28
View File
@@ -1,28 +0,0 @@
# Plan: logrotate-mode (mail.log stops rotating in some images)
**Status:** done
**Version:** patch; no schema, no configuration surface.
---
## Summary
`mail.log` stopped rotating when `/etc/logrotate.d/mail` landed in the image
with group/other write permission (e.g. build context from a Windows tar sync).
logrotate ignores such configs but exits 0, so the loop looked healthy while
the log grew without bound.
## What shipped
- [`build/Dockerfile`](../../build/Dockerfile): `COPY --chmod` pins config
(`0644`) and script (`0755`) modes so the image no longer depends on checkout
file modes.
- [`build/logrotate-loop.sh`](../../build/logrotate-loop.sh): preflight and
per-iteration checks refuse group/other-writable configs; logrotate stderr
mentioning `Ignoring` is fatal.
- [`test/e2e/logrotate_check.go`](../../test/e2e/logrotate_check.go): asserts
mode `644`, forced `logrotate -f`, and that a group-writable context file
still produces `644` in the image.
`create 0640 postfix selfpost` in [`build/logrotate-mail.conf`](../../build/logrotate-mail.conf)
is unchanged — required for panel readability after rotation.
+141
View File
@@ -0,0 +1,141 @@
# Plan: queue-retries (Postfix retry policy in the panel)
**Status:** agreed
**Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay.
---
## Goal
Show the operator how Postfix retries deferred mail: first retry delay, later
backoff cap, and how long a message stays in the queue before it bounces. The
numbers come from this container's effective Postfix config, not from
hard-coded copy.
## Scope
**In:**
- A static «How delivery retries work» card on `/mail-queue` (global
administrator). Not inside the HTMX poll fragment — the snapshot is taken at
panel start.
- The same human-readable intervals in `/deliveries/{id}` history for
`deferred` and `bounced` (domain administrators never see Mail queue).
- Operator docs: [guide.md](../guide.md) Mail queue bullet;
[architecture.md](../architecture.md) notes the one-shot `postconf -h` at
panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`.
**Out:**
- Changing Postfix retry parameters, or exposing them as panel settings
([product.md](../product.md): Postfix is used as-is).
- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no
attempt budget; it is time-based.
- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each
attempt is already on the delivery page's log table; the journal stores only
the last status.
- Duplicating the card on Status (summary + link to Mail queue already exist).
- Re-reading `postconf` on every HTTP request.
## Architecture
Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk
queue. SelfPost does not enqueue, retry, or deliver.
```mermaid
flowchart LR
smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"]
postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"]
retry -->|sent| done["sent"]
retry -->|lifetime elapsed or 5xx| bounce["bounced"]
postfixQueue --> mailQueuePage["/mail-queue policy card"]
postfixQueue --> deliveryPage["/deliveries/id history copy"]
```
[build/postfix-config.sh](../../build/postfix-config.sh) does not set
`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`,
`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`.
Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator
overrides them (`postconf -e` inside the container).
### Loading the numbers
Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go),
after `postfix-config.sh` has run):
```
postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time
```
`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not
written to the file. `postconf` is the effective config, including a manual
override.
- Fixed argv, no user input — same pattern as
[postfix.Queue](../../internal/postfix/queue.go) /
[security.md](../security.md).
- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not
call `postconf`.
- A live `postconf -e` is visible after the next panel (or container) restart.
While the process is up, the panel shows the start-up snapshot.
- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in
`internal/postfix`. Format human strings (`5 minutes`, `5 days`,
`about 1 hour 7 minutes`) in one place so the Mail queue card and
`deliveryEvents` cannot drift.
- If `postconf` is missing (unit tests on Windows, binary outside the
container): log a warning, fall back to Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests
stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on
`Config`. Do not fail panel start.
Typical stock values, for orientation only — the UI prints whatever
`postconf` returned:
| Parameter | Stock | Meaning |
|---|---|---|
| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan |
| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) |
| `maximal_queue_lifetime` | `5d` | Then bounce |
| `delay_warning_time` | `0` | No delay warning to the sender |
## Panel copy
Mail queue card facts: first retry; later retries (doubling, capped);
kept in queue; then bounced. Short prose: there is no fixed attempt count; a
`deferred` message stays in this listing until it is delivered or the queue
lifetime runs out.
`deliveryEvents(row, policy)`:
- `deferred`: retries, first after X, then with increasing gaps up to Y, for
up to Z.
- `bounced`: or Postfix gave up after Z in the queue.
## Tests
- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number.
- `/mail-queue` handler: card shows the fixture policy's human strings, not a
live `postconf`.
- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those
strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)).
- [templates_test.go](../../internal/web/view/templates_test.go): pass the new
fields if rendering `mail_queue` requires them.
`go test` / `go vet` on the touched packages.
## Done when
- `/mail-queue` states this Postfix's first retry, backoff cap, and queue
lifetime.
- A `deferred` / `bounced` delivery page uses the same intervals.
- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart
changes what the panel prints.
- Guide and architecture describe the snapshot; CHANGELOG has an Added entry.
## Risks
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
operator had overridden them — mitigate with the muted note on the card.
- Inventing a max-attempt count would be false; the copy must stay time-based.
+73 -7
View File
@@ -15,7 +15,9 @@ planned but unlisted.
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long **Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
an explicit break. an explicit break. One such break, when 2.x is cut for any reason, is
[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain
with a baseline. That item does not by itself justify a major.
**Process:** [development.md](development.md). The history of closed phases is **Process:** [development.md](development.md). The history of closed phases is
in `git log` and [CHANGELOG.md](../CHANGELOG.md). in `git log` and [CHANGELOG.md](../CHANGELOG.md).
@@ -26,20 +28,46 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Plan | | ID | Topic | Status | Plan |
|---|---|---|---| |---|---|---|---|
| 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 | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.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** | — |
**Recommended order** (not binding): **inbound-relay** first among agreed **Recommended order** (not binding): **queue-retries** is a small panel item
items — it is the largest remaining 1.x+ extension. Candidates need explicit that can land first or in parallel; the next feature is **inbound-relay**.
agreement before they join the queue. The 2026-08-13 full-tree review follow-ups (send-log authorization,
fail-closed paths, docs, GUI, tests, licence) are closed — history in
[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.
--- ---
## queue-retries
**Goal:** show on Mail queue and on a delivery's history how this Postfix
retries deferred mail — first delay, backoff cap, queue lifetime — reading
the effective config (`postconf -h`) once at panel start so a manual
override is visible.
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no
panel knobs for queue lifetime, no schema change. Domain administrators see
the intervals on `/deliveries/{id}` (they cannot open Mail queue).
**Done when:** see the criteria in
[plans/queue-retries.md](plans/queue-retries.md).
**Dependencies / risks:** `postconf` unavailable outside the container
(fallback + muted note). Copy must stay time-based — Postfix has no max
attempt count.
**Version:** patch.
---
## inbound-relay ## inbound-relay
**Goal:** optional acceptance of mail on port 25 for explicitly configured **Goal:** optional acceptance of mail on port 25 for explicitly configured
@@ -94,9 +122,10 @@ reports (`ruf=`) out of scope for v1.
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure **Dependencies / risks:** inbound SMTP in the image (may share infrastructure
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX); with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
storage and retention of parsed summaries; the `admin.dmarc_report_email` and storage and retention of parsed summaries; the `dmarc_report_email` setting
`domains.dmarc_rua` settings added in the DMARC template work must stay the (migration `0005` moved it off the old `admin` table into `settings`) and
source of truth for `rua=` in DNS guidance. `domains.dmarc_rua` added in the DMARC template work must stay the source of
truth for `rua=` in DNS guidance.
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with **Order:** after the DMARC `rua=` settings ship; may follow or overlap with
inbound-relay depending on how port 25 acceptance is structured. inbound-relay depending on how port 25 acceptance is structured.
@@ -127,3 +156,40 @@ when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed. **Version:** `1.x` MINOR; `candidate` until explicitly agreed.
---
## schema-squash
**Goal:** when 2.x is cut, stop shipping the 1.x migration files
(`0001_init.sql``0005_panel_users.sql`) in the binary and replace them with
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data
directories no longer create-then-drop the historical `admin` table.
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots.
Do not delete, rename, or reorder those files while MINOR compatibility with
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing
databases. Git history keeps the old files either way; only the embedded set
in the 2.x image changes.
**Upgrade gate (required with the squash):**
| `user_version` | 2.x behaviour |
|---|---|
| `0` (empty file) | Apply the baseline; set `user_version` to the new chains head |
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
| `1``4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
Restore remains a separate lock: the backup manifest version must match the
running binary ([architecture.md](architecture.md) § Persistence). It does not
replace this gate.
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
it); the gate above is tested; the operator guide says a 2.x image will not
open an unfinished 1.x database.
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
explicit major). Squashing five short files is not a reason to cut 2.x on its
own. A missed gate leaves a `user_version = 3` database silently stuck.
**Version:** `2.x` major only; not a 1.x item.
+59 -24
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.
@@ -34,8 +39,8 @@ The panel is exposed to the internet — the items below are **not optional**.
- Failed attempts do **not** invalidate the token early (protects setup from - Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed). being DoS-ed).
- Once the administrator exists the token is void forever, `/setup/*` → 404. - Once the administrator exists the token is void forever, `/setup/*` → 404.
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext - The administrator password is bcrypt in SQLite only; no plaintext and no
and no MD5. MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**. - `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
### Application SASL passwords ### Application SASL passwords
@@ -112,19 +117,34 @@ deferred item from the roadmap.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.** - **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: the panel is single-user, the administrator picks the browser, deliberately: every panel user (global or domain-admin) is an operator who
and a strict mode would not "protect" such a client, it would simply break the picks their own browser, not an untrusted party the panel needs to defend
panel in it. Tightening is one line in `originAllowed` against, and a strict mode would not "protect" such a client, it would
simply break the panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false` ([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch. instead of `true` in the "neither header present" branch.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the - **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to not. The price is a hidden field in roughly two dozen forms. The trigger to
revisit is a requirement for protection that holds regardless of the browser. revisit is a requirement for protection that holds regardless of the browser,
A token would not save the panel from XSS inside it either: code executing in or a domain-admin population the global administrator does not fully trust
the panel's origin sends the request itself — against that, `html/template` (see the ADR below). A token would not save the panel from XSS inside it
auto-escaping and CSP do the work, which is why templates must contain no either: code executing in the panel's origin sends the request itself —
inline scripts and no inline styles. against that, `html/template` auto-escaping and CSP do the work, which is why
templates must contain no inline scripts and no inline styles.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
or blocked the form submits immediately, exactly as it did before the
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
not an authorization boundary — the same origin check and session/RBAC
gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
`domain_delete.html` already do for the two highest-blast-radius deletes.
- **Encrypting backups and exports is an option, not the default.** With the - **Encrypting backups and exports is an option, not the default.** With the
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to keep a password would lose the ability to take a operator with nowhere to keep a password would lose the ability to take a
@@ -158,24 +178,35 @@ deferred item from the roadmap.
**Context.** The panel is forms (`POST`) with a cookie session — the classic **Context.** The panel is forms (`POST`) with a cookie session — the classic
CSRF surface. What is needed is a way to tell a request from the panel's own CSRF surface. What is needed is a way to tell a request from the panel's own
page apart from one initiated by a third-party site in the logged-in page apart from one initiated by a third-party site in a logged-in user's
administrator's browser. browser. The panel is multi-user since 1.2.0 (a global administrator plus
zero or more domain-admin users, each scoped to their assigned domains), but
that is an authorization boundary (who can see or change what), not a change
to the CSRF threat: the attacker in scope here is still an external site
riding a legitimate user's cookie, not one panel user attacking another
through the browser.
**Decision.** `originAllowed` in **Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site` [internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(when the browser sends it) or `Origin` (fallback) against the panel's host; a (when the browser sends it) or `Origin` (fallback) against the panel's host; a
request carrying neither header is **allowed through** rather than rejected. request carrying neither header is **allowed through** rather than rejected.
There are no session-bound tokens embedded in forms. There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role.
**Why not tokens.** The panel is single-user (one administrator per instance) — **Why not tokens.** Cross-user CSRF is not the threat model here: a
the threat model does not include cross-user CSRF inside the panel itself, only domain-admin's browser sending a request still needs that domain-admin's own
an external site making the administrator's browser send a request. The origin cookie, so a token would not add a boundary between roles that the
check covers that without touching a single template: a token would need a authorization checks (`Principal.CanAccessDomain`,
hidden field in roughly two dozen forms and synchronisation with every new form, [internal/web/auth/principal.go](../internal/web/auth/principal.go); route
and it would still not protect against XSS inside the panel — code executing in gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
the panel's origin reads the token and sends the request itself. XSS is handled don't already enforce. The remaining case is an external site making a
by `html/template` auto-escaping and CSP, so that is a separate line of defence, logged-in user's browser send a request, which the origin check covers without
not a CSRF token. touching a single template.
A token would need a hidden field in roughly two dozen forms and
synchronisation with every new form, and it would still not protect against
XSS inside the panel — code executing in the panel's origin reads the token
and sends the request itself. XSS is handled by `html/template` auto-escaping
and CSP, so that is a separate line of defence, not a CSRF token.
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a **Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
@@ -183,7 +214,11 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
such a client, at the price of a narrow residual surface. such a client, at the price of a narrow residual surface.
**Revisit if:** a requirement appears for protection that does not depend on **Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or the panel becomes multi-user. browser behaviour, or domain-admin accounts stop being trusted operators (for
example, if a future release lets a global administrator invite domain-admins
whose browsers/devices are not vetted) — at that point cross-role request
forgery inside the panel would need its own analysis, separate from the
external-site case this ADR covers.
## How this list grows ## How this list grows
+11 -1
View File
@@ -166,14 +166,24 @@ func (s *Service) RegeneratePassword(id int64) (string, error) {
// Delete removes an application: its SASL account, its registry row (and address // Delete removes an application: its SASL account, its registry row (and address
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec // rows via cascade) and its sender-map bindings, then reloads Postfix (spec
// 7.2.8). The domain and other applications are untouched. // 7.2.8). The domain and other applications are untouched.
//
// The order matches domain deletion: the SASL account goes first, while the
// login is still in the registry. Dropping the row first would, on a
// saslpasswd2 failure, leave an account that can still authenticate to Postfix
// but that the panel no longer knows about — an orphan no operator can see or
// remove. Failing before the row is deleted is recoverable: the application is
// still listed and the delete can be retried.
func (s *Service) Delete(id int64) error { func (s *Service) Delete(id int64) error {
a, err := s.store.DeleteApplication(id) a, err := s.store.GetApplication(id)
if err != nil { if err != nil {
return err return err
} }
if err := s.sasl.Delete(a.Login); err != nil { if err := s.sasl.Delete(a.Login); err != nil {
return err return err
} }
if _, err := s.store.DeleteApplication(id); err != nil {
return err
}
// Drop the application's level-2 limit, if any (guide § Rate limiting); // Drop the application's level-2 limit, if any (guide § Rate limiting);
// rate_limits has no cascade of its own. // rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil { if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
+31
View File
@@ -181,6 +181,37 @@ func TestServiceDelete(t *testing.T) {
} }
} }
// If sasldb2 cannot be updated the application must stay in the registry: an
// account that still authenticates but has no panel row is invisible to the
// operator and cannot be deleted again.
func TestServiceDeleteKeepsRowWhenSASLFails(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
rec.failNext = true // saslpasswd2 -d fails
if err := svc.Delete(a.ID); err == nil {
t.Fatal("Delete reported success although the SASL account was not removed")
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 1 {
t.Fatalf("registry row dropped while the SASL account can still authenticate: %+v", apps)
}
if _, ok := rec.set["app1"]; !ok {
t.Fatal("SASL account gone despite the failure — the harness no longer proves the ordering")
}
// The delete is retryable now that the row is still there.
if err := svc.Delete(a.ID); err != nil {
t.Fatalf("retried Delete: %v", err)
}
if _, ok := rec.set["app1"]; ok {
t.Error("SASL account not deleted on retry")
}
}
func TestServiceUpdateMode(t *testing.T) { func TestServiceUpdateMode(t *testing.T) {
svc, st, _, maps := newServiceHarness(t) svc, st, _, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com") d := addDomain(t, st, "example.com")
+23 -16
View File
@@ -6,13 +6,19 @@
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix // it. TLS certificates (the reverse proxy's responsibility) and the Postfix
// queue are deliberately excluded (architecture.md § Persistence). // queue are deliberately excluded (architecture.md § Persistence).
// //
// Restore is intentionally not a separate code path: a backup is extracted // Restore is not a separate code path in the panel: a backup is extracted into
// into the /data bind mount before first start, and the panel regenerates // the /data bind mount before first start, and the archive already carries
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal // everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender
// start. The only restore-specific step is CheckRestore, which refuses to boot // map — so the daemons can start on the extracted files alone. The
// if the manifest's version does not match the running binary, so // restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so
// schema/format skew between versions cannot silently corrupt state // schema/format skew between versions cannot silently corrupt state
// (architecture.md § Persistence). // (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
// and the Postfix sender map from SQLite on that first boot, so any drift
// between the archive and the database is healed before mail flows. If
// on-disk state drifts again later — for example after a manual edit under
// /data — the Status page's "Reload configuration" button runs the same
// Resync on demand.
package backup package backup
import ( import (
@@ -264,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
+3 -2
View File
@@ -14,8 +14,9 @@ import (
var License []byte var License []byte
// SourceURL is where Corresponding Source for the published upstream // SourceURL is where Corresponding Source for the published upstream
// releases lives. Operators who ship a modified version must point their // releases lives. It is injected into the panel footer by view.go.
// users at their own sources instead (NOTICE; AGPL-3.0 §13). // Operators who ship a modified version must change this constant so the
// footer points at their own sources (NOTICE; AGPL-3.0 §13).
const SourceURL = "https://github.com/mixeme/selfpost" const SourceURL = "https://github.com/mixeme/selfpost"
// CopyrightLine is the short copyright notice shown in the panel footer. // CopyrightLine is the short copyright notice shown in the panel footer.
+19
View File
@@ -19,3 +19,22 @@ func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
t.Fatal("embedded LICENSE is empty") t.Fatal("embedded LICENSE is empty")
} }
} }
// NOTICE used to tell modifiers to edit layout.html for the Source URL. The
// footer reads legal.SourceURL; a fork that only changed the template would
// still advertise the upstream repo.
func TestNoticePointsAtSourceURLConstant(t *testing.T) {
notice, err := os.ReadFile(filepath.Join("..", "..", "NOTICE"))
if err != nil {
t.Fatalf("read NOTICE: %v", err)
}
if !bytes.Contains(notice, []byte("internal/legal/legal.go")) {
t.Error("NOTICE must tell modifiers to update SourceURL in internal/legal/legal.go")
}
if bytes.Contains(notice, []byte("layout.html")) {
t.Error("NOTICE still tells modifiers to edit layout.html for the Source URL")
}
if !bytes.Contains(notice, []byte("internal/web/view/static/OFL.txt")) {
t.Error("NOTICE must point at the OFL text that travels with the Plex fonts")
}
}
+30 -8
View File
@@ -36,6 +36,29 @@ type inflight struct {
m map[string]map[*reservation]struct{} m map[string]map[*reservation]struct{}
} }
// tryAdmit decides whether one more message may be sent for key and, if so,
// claims its slot — both under a single lock. Counting and reserving in two
// separate critical sections would let two SMTP sessions that reach MAIL FROM
// at the same moment observe the same total and each take the last free slot,
// which is exactly the overshoot the in-flight registry exists to prevent.
//
// stored is the count the send log already holds for the limit's window and max
// is the ceiling; the caller supplies both because only it can query the store.
// The returned total is what was measured, for the refusal log line.
func (f *inflight) tryAdmit(key string, since time.Time, stored, max int64) (*reservation, int64, bool) {
if f == nil {
return nil, stored, stored < max // no in-flight accounting (tests)
}
f.mu.Lock()
defer f.mu.Unlock()
total := stored + f.countLocked(key, since)
if total >= max {
return nil, total, false
}
return f.reserveLocked(key), total, true
}
// count returns how many reservations for key were taken within the limit's // count returns how many reservations for key were taken within the limit's
// window (at or after since), pruning any that outlived reservationTTL. // window (at or after since), pruning any that outlived reservationTTL.
func (f *inflight) count(key string, since time.Time) int64 { func (f *inflight) count(key string, since time.Time) int64 {
@@ -45,6 +68,11 @@ func (f *inflight) count(key string, since time.Time) int64 {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
return f.countLocked(key, since)
}
// countLocked is count's body; the caller holds mu.
func (f *inflight) countLocked(key string, since time.Time) int64 {
set := f.m[key] set := f.m[key]
cutoff := time.Now().Add(-reservationTTL) cutoff := time.Now().Add(-reservationTTL)
var n int64 var n int64
@@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 {
return n return n
} }
// reserve claims a slot for key until the message is recorded or released. // reserveLocked is reserve's body; the caller holds mu.
func (f *inflight) reserve(key string) *reservation { func (f *inflight) reserveLocked(key string) *reservation {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.m == nil { if f.m == nil {
f.m = make(map[string]map[*reservation]struct{}) f.m = make(map[string]map[*reservation]struct{})
} }
+111 -1
View File
@@ -3,6 +3,7 @@ package milter
import ( import (
"errors" "errors"
"net" "net"
"sync"
"testing" "testing"
"time" "time"
@@ -15,7 +16,10 @@ import (
// swallows recorder errors and still accepts the message. By default it reports // swallows recorder errors and still accepts the message. By default it reports
// no configured rate limit, so the level-2 check is inert unless a test sets // no configured rate limit, so the level-2 check is inert unless a test sets
// limits (see fakeRecorder fields). // limits (see fakeRecorder fields).
// mu guards the recorded slices so several sessions may drive one recorder
// concurrently, as they do in the real server.
type fakeRecorder struct { type fakeRecorder struct {
mu sync.Mutex
entries []store.SendLogEntry entries []store.SendLogEntry
rejected []store.SendLogEntry rejected []store.SendLogEntry
fail bool fail bool
@@ -27,17 +31,25 @@ type fakeRecorder struct {
counts map[string]int64 counts map[string]int64
lookupErr error lookupErr error
countErr error countErr error
// onCount, if set, runs inside CountMessages. It lets a test hold every
// racing session at the store lookup until they can all proceed together.
onCount func()
} }
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error { func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
if f.fail { if f.fail {
return errors.New("boom") return errors.New("boom")
} }
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, e) f.entries = append(f.entries, e)
return nil return nil
} }
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error { func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
f.mu.Lock()
defer f.mu.Unlock()
f.rejected = append(f.rejected, e) f.rejected = append(f.rejected, e)
return nil return nil
} }
@@ -57,6 +69,9 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
if f.countErr != nil { if f.countErr != nil {
return 0, f.countErr return 0, f.countErr
} }
if f.onCount != nil {
f.onCount()
}
return f.counts[scope+"|"+ref], nil return f.counts[scope+"|"+ref], nil
} }
@@ -415,6 +430,58 @@ func TestRateLimitCountsInFlightMessages(t *testing.T) {
} }
} }
// The case above is sequential: the second session reads the stored count after
// the first has already reserved. Here every session reads it first — the gate
// holds them all inside the lookup — which is what concurrent SMTP connections
// actually do. However many then race for the single free slot, exactly one may
// pass. (TestTryAdmitHandsOutEachSlotOnce is the test that fails when counting
// and reserving are not one step; this one pins the session-level behaviour.)
func TestRateLimitAdmitsOnlyOneRacingSession(t *testing.T) {
rec := limitedRecorder(4) // one below the ceiling of 5
fl := &inflight{}
gate := make(chan struct{})
// Every session is held inside the stored-count lookup until all of them
// have read it, which is the state the race needs: none of them can see
// another's reservation, because none has been taken yet.
const racers = 32
var atCount, done sync.WaitGroup
atCount.Add(racers)
go func() { atCount.Wait(); close(gate) }()
rec.onCount = func() { atCount.Done(); <-gate }
responses := make([]milter.Response, racers)
for i := range racers {
done.Add(1)
go func() {
defer done.Done()
// Connect is skipped so every goroutine starts from the same point;
// the client IP is what Connect would have captured.
s := &session{rec: rec, flight: fl, clientIP: limitIP}
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
if err != nil {
resp = nil // reported as a missing Continue below
}
responses[i] = resp
}()
}
done.Wait()
admitted := 0
for _, resp := range responses {
if resp == milter.RespContinue {
admitted++
}
}
if admitted != 1 {
t.Fatalf("%d of %d racing sessions admitted, want exactly 1 — the last slot was handed out twice",
admitted, racers)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 1 {
t.Fatalf("in-flight reservations = %d, want 1", n)
}
}
// Once the message is recorded the stored count sees it, so its reservation // Once the message is recorded the stored count sees it, so its reservation
// must be given back — otherwise it would be counted twice and the ceiling // must be given back — otherwise it would be counted twice and the ceiling
// would drift closed. // would drift closed.
@@ -477,12 +544,55 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
} }
} }
// The ceiling is handed out exactly max times however the sessions interleave.
// Counting and reserving in two critical sections passes the sequential tests
// above and still overshoots here, because between one session's count and its
// reservation any number of others can pass the same check.
func TestTryAdmitHandsOutEachSlotOnce(t *testing.T) {
const (
max = 500
workers = 8
)
fl := &inflight{}
since := time.Now().Add(-time.Hour)
start := make(chan struct{})
admitted := make([]int, workers)
var wg sync.WaitGroup
for i := range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for {
_, _, ok := fl.tryAdmit("domain|example.com", since, 0, max)
if !ok {
return
}
admitted[i]++
}
}()
}
close(start)
wg.Wait()
total := 0
for _, n := range admitted {
total += n
}
if total != max {
t.Fatalf("admitted %d messages under a ceiling of %d", total, max)
}
}
// The in-flight count only covers the limit's own window: a reservation older // The in-flight count only covers the limit's own window: a reservation older
// than it (a session stuck mid-DATA for longer than the window) must not be // than it (a session stuck mid-DATA for longer than the window) must not be
// counted against a window it no longer belongs to. // counted against a window it no longer belongs to.
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) { func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
fl := &inflight{} fl := &inflight{}
r := fl.reserve("domain|example.com") r, _, ok := fl.tryAdmit("domain|example.com", time.Now().Add(-time.Hour), 0, 1)
if !ok {
t.Fatal("tryAdmit refused the first message under a ceiling of 1")
}
r.at = time.Now().Add(-time.Minute) r.at = time.Now().Add(-time.Minute)
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 { if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
+7 -6
View File
@@ -54,22 +54,23 @@ func (s *session) overLimit() bool {
} }
// enforceLimit counts recent messages for scope/ref and refuses when at or // enforceLimit counts recent messages for scope/ref and refuses when at or
// above the ceiling. On admit it reserves an in-flight slot on the session. // above the ceiling. The stored count and the in-flight slots are weighed and
// the admitted message's own slot is taken in one atomic step (tryAdmit), so
// two sessions racing at MAIL FROM cannot both claim the last free slot.
func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool { func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool {
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second) since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
n, err := s.rec.CountMessages(scope, ref, since) stored, err := s.rec.CountMessages(scope, ref, since)
if err != nil { if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err) log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
return false return false
} }
key := scope + "|" + ref r, n, ok := s.flight.tryAdmit(scope+"|"+ref, since, stored, int64(rl.MaxMessages))
n += s.flight.count(key, since) if !ok {
if n >= int64(rl.MaxMessages) {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx", log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP) scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
return true return true
} }
s.reserved = append(s.reserved, s.flight.reserve(key)) s.reserved = append(s.reserved, r)
return false return false
} }
+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
+26
View File
@@ -75,6 +75,32 @@ func (s *Store) ListDomains() ([]Domain, error) {
return out, rows.Err() return out, rows.Err()
} }
// ListDomainsForUser returns domains assigned to userID with application counts,
// ordered by name.
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
INNER JOIN user_domains ud ON ud.domain_id = d.id
WHERE ud.user_id = ?
ORDER BY d.name`, userID)
if err != nil {
return nil, fmt.Errorf("list domains for user: %w", err)
}
defer rows.Close()
var out []Domain
for rows.Next() {
d, err := scanDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetDomain returns a single domain (with its application count) by id, or // GetDomain returns a single domain (with its application count) by id, or
// ErrDomainNotFound. // ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) { func (s *Store) GetDomain(id int64) (Domain, error) {
+30 -2
View File
@@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
return row, nil return row, nil
} }
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or // SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of
// application login. An empty field matches everything. // narrowing, and they behave in opposite ways on purpose.
//
// Domain and AppLogin are the operator's own filters, chosen in the UI: an
// empty field matches everything.
//
// Domains and AllDomains are the authorization scope, which no query parameter
// may widen. Domains is the exhaustive set of domain names the caller is
// entitled to read, applied as an IN constraint; AllDomains lifts that
// restriction and is the only way to read the whole journal. A zero-valued
// filter therefore matches *no* rows: a caller that forgets to state a scope
// gets an empty log rather than every tenant's mail, which is the failure mode
// this struct exists to make impossible.
type SendLogFilter struct { type SendLogFilter struct {
Domain string Domain string
AppLogin string AppLogin string
Domains []string
AllDomains bool
} }
// QuerySendLog returns send-log rows matching filter, newest first, for the // QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (product.md's send-log view). // monitoring screen's server-side pagination (product.md's send-log view).
// The filter's authorization scope is mandatory: see SendLogFilter.
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) { func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter) where, args := sendLogWhere(filter)
args = append(args, limit, offset) args = append(args, limit, offset)
@@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
func sendLogWhere(f SendLogFilter) (string, []any) { func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string var clauses []string
var args []any var args []any
if !f.AllDomains {
// No scope is not "no restriction": a domain administrator whose last
// assignment was deleted owns nothing in the journal and must see
// nothing, and the same clause catches a caller that never set a scope.
if len(f.Domains) == 0 {
return " WHERE 1 = 0", nil
}
marks := make([]string, len(f.Domains))
for i, name := range f.Domains {
marks[i] = "?"
args = append(args, name)
}
clauses = append(clauses, "domain IN ("+strings.Join(marks, ", ")+")")
}
if f.Domain != "" { if f.Domain != "" {
clauses = append(clauses, "domain = ?") clauses = append(clauses, "domain = ?")
args = append(args, f.Domain) args = append(args, f.Domain)
+57
View File
@@ -1,6 +1,8 @@
package store package store
import ( import (
"sort"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -162,6 +164,61 @@ func TestUpdateStatusNoMatch(t *testing.T) {
} }
} }
// The journal is read by principals who are only entitled to part of it, so
// the scope is part of the query rather than something the caller remembers to
// apply afterwards. A filter that states no scope is a caller that has not
// decided who is asking, and the safe answer to that is nothing.
func TestSendLogScopeIsMandatory(t *testing.T) {
st := openTestStore(t)
for _, domain := range []string{"first.example.ru", "second.example.ru"} {
if err := st.InsertQueued(SendLogEntry{
QueueID: "Q-" + domain, Domain: domain, AppLogin: "app-" + domain,
From: "noreply@" + domain, To: "public@example.net", Subject: domain,
}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
for name, tc := range map[string]struct {
filter SendLogFilter
want []string
}{
"no scope": {SendLogFilter{}, nil},
"empty scope": {SendLogFilter{Domains: []string{}}, nil},
"all domains": {SendLogFilter{AllDomains: true}, []string{"first.example.ru", "second.example.ru"}},
"one domain": {SendLogFilter{Domains: []string{"first.example.ru"}}, []string{"first.example.ru"}},
"two domains": {SendLogFilter{Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru", "second.example.ru"}},
"unknown domain": {SendLogFilter{Domains: []string{"third.example.ru"}}, nil},
"filter within": {SendLogFilter{Domain: "first.example.ru", Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru"}},
"filter outside": {SendLogFilter{Domain: "second.example.ru", Domains: []string{"first.example.ru"}}, nil},
"app filter outside": {SendLogFilter{AppLogin: "app-second.example.ru", Domains: []string{"first.example.ru"}}, nil},
} {
rows, err := st.QuerySendLog(tc.filter, 50, 0)
if err != nil {
t.Fatalf("%s: QuerySendLog: %v", name, err)
}
var got []string
for _, r := range rows {
got = append(got, r.Domain)
}
// Which rows came back is the question here; the page's own order is
// newest-first and is tested where it matters.
sort.Strings(got)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("%s: rows for %v, want %v", name, got, tc.want)
}
// The count drives pagination, so it has to agree with the page or the
// UI advertises pages of rows the reader is not allowed to see.
n, err := st.CountSendLog(tc.filter)
if err != nil {
t.Fatalf("%s: CountSendLog: %v", name, err)
}
if int(n) != len(tc.want) {
t.Errorf("%s: count %d, want %d", name, n, len(tc.want))
}
}
}
func TestDeleteSendLogBefore(t *testing.T) { func TestDeleteSendLogBefore(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
+4 -3
View File
@@ -1,7 +1,8 @@
// Package store owns the SelfPost SQLite database: the single file under /data // Package store owns the SelfPost SQLite database: the single file under /data
// that persists the administrator account, sending domains and applications, // that persists panel users (global administrators and domain-admins), sending
// the send log and rate-limit settings (architecture.md § Persistence). It // domains and applications, the send log and rate-limit settings
// exposes typed queries so the rest of the panel never builds SQL by hand. // (architecture.md § Persistence). It exposes typed queries so the rest of the
// panel never builds SQL by hand.
package store package store
import ( import (
+2
View File
@@ -51,6 +51,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
loginLimiter: newRateLimiter(10, 15*time.Minute), loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs, trustedProxies: cfg.TrustedProxyCIDRs,
} }
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m return m
} }
+74 -7
View File
@@ -1,13 +1,17 @@
package auth package auth
import ( import (
"database/sql"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view" "github.com/mixeme/selfpost/internal/web/view"
) )
@@ -22,6 +26,15 @@ func newTestSessionStore(t *testing.T) *sessionStore {
return newSessionStore(st, 7*24*time.Hour) return newSessionStore(st, 7*24*time.Hour)
} }
func mustCreate(t *testing.T, s *sessionStore, username string) string {
t.Helper()
token, err := s.Create(username)
if err != nil {
t.Fatalf("create session: %v", err)
}
return token
}
func mustView(t *testing.T) *view.Engine { func mustView(t *testing.T) *view.Engine {
t.Helper() t.Helper()
v, err := view.New("test") v, err := view.New("test")
@@ -86,7 +99,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
m := testModule(t, false) m := testModule(t, false)
token := m.sessions.Create("admin") token := mustCreate(t, m.sessions, "admin")
reached := false reached := false
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
@@ -107,7 +120,7 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
func TestLogoutClearsBothCookieNames(t *testing.T) { func TestLogoutClearsBothCookieNames(t *testing.T) {
m := testModule(t, true) m := testModule(t, true)
token := m.sessions.Create("admin") token := mustCreate(t, m.sessions, "admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com" r.Host = "panel.example.com"
@@ -132,9 +145,63 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
} }
} }
// A session that could not be stored must not turn into a cookie: the browser
// would look signed in, and every request it made would be bounced to /login
// with no explanation. Only the sessions table is broken here, so the request
// gets past the user lookup and password check and fails exactly where the
// session is written.
func TestLoginSetsNoCookieWhenTheSessionCannotBeStored(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
hash, err := bcrypt.GenerateFromPassword([]byte("correct-horse-battery"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
dropSessionsTable(t, path)
m := New(st, Config{}, mustView(t), "")
form := url.Values{"username": {"admin"}, "password": {"correct-horse-battery"}}
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
m.HandleLogin(rec, r)
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
t.Errorf("a session cookie was issued for a session that was never stored: %v", got)
}
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (the login failed)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Errorf("the browser was sent to %q as if it were signed in", loc)
}
}
// dropSessionsTable breaks session persistence while leaving the rest of the
// schema usable. The SQLite driver is registered by internal/store.
func dropSessionsTable(t *testing.T, path string) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("open database directly: %v", err)
}
defer db.Close()
if _, err := db.Exec("DROP TABLE sessions"); err != nil {
t.Fatalf("drop sessions table: %v", err)
}
}
func TestSessionRename(t *testing.T) { func TestSessionRename(t *testing.T) {
s := newTestSessionStore(t) s := newTestSessionStore(t)
token := s.Create("admin") token := mustCreate(t, s, "admin")
s.Rename(token, "operator") s.Rename(token, "operator")
@@ -149,8 +216,8 @@ func TestSessionRename(t *testing.T) {
func TestSessionDestroyOthers(t *testing.T) { func TestSessionDestroyOthers(t *testing.T) {
s := newTestSessionStore(t) s := newTestSessionStore(t)
keep := s.Create("admin") keep := mustCreate(t, s, "admin")
other := s.Create("admin") other := mustCreate(t, s, "admin")
s.DestroyOthers(keep) s.DestroyOthers(keep)
@@ -165,7 +232,7 @@ func TestSessionDestroyOthers(t *testing.T) {
func TestSessionLookupRejectsExpired(t *testing.T) { func TestSessionLookupRejectsExpired(t *testing.T) {
s := newTestSessionStore(t) s := newTestSessionStore(t)
s.idle = -time.Minute s.idle = -time.Minute
token := s.Create("admin") token := mustCreate(t, s, "admin")
if _, ok := s.Lookup(token); ok { if _, ok := s.Lookup(token); ok {
t.Fatal("expired session was accepted") t.Fatal("expired session was accepted")
@@ -174,7 +241,7 @@ func TestSessionLookupRejectsExpired(t *testing.T) {
func TestSessionTouchThrottled(t *testing.T) { func TestSessionTouchThrottled(t *testing.T) {
s := newTestSessionStore(t) s := newTestSessionStore(t)
token := s.Create("admin") token := mustCreate(t, s, "admin")
if s.Touch(token) { if s.Touch(token) {
t.Fatal("touch renewed a session created moments ago") t.Fatal("touch renewed a session created moments ago")
+6 -1
View File
@@ -120,7 +120,12 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
token := m.sessions.Create(user.Username) token, err := m.sessions.Create(user.Username)
if err != nil {
logf("panel: login: create session failed: %v", err)
m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.")
return
}
m.setSessionCookie(w, token) m.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
+284
View File
@@ -0,0 +1,284 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
const testPassword = "correct-horse-battery"
// moduleWithAdmin returns a panel that has already been through setup, with one
// global administrator whose password is testPassword.
func moduleWithAdmin(t *testing.T) *Module {
t.Helper()
m := testModule(t, false)
hash, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := m.store.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
return m
}
// postLogin submits the sign-in form from remoteAddr (the limiter's key) and
// returns what the handler wrote.
func postLogin(m *Module, remoteAddr, username, password string) *httptest.ResponseRecorder {
form := url.Values{"username": {username}, "password": {password}}
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
m.HandleLogin(rec, r)
return rec
}
// sessionCookieValue returns the session token the response issued, or "" if it
// issued none.
func sessionCookieValue(t *testing.T, m *Module, rec *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == m.sessionCookie() {
return c.Value
}
}
return ""
}
func TestLoginSignsInWithTheRightPassword(t *testing.T) {
m := moduleWithAdmin(t)
rec := postLogin(m, "203.0.113.7:5000", "admin", testPassword)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("status = %d, Location = %q; want a redirect to /", rec.Code, rec.Header().Get("Location"))
}
token := sessionCookieValue(t, m, rec)
if token == "" {
t.Fatal("no session cookie was issued")
}
name, ok := m.sessions.Lookup(token)
if !ok || name != "admin" {
t.Fatalf("the cookie's session resolves to %q, %t; want admin", name, ok)
}
}
// A refused sign-in must not say which half was wrong: the panel is public, and
// distinguishable answers would turn the form into a list of usernames.
func TestLoginRefusesBadCredentialsWithoutSayingWhy(t *testing.T) {
m := moduleWithAdmin(t)
bodies := make(map[string]string, 2)
for name, creds := range map[string][2]string{
"wrong password": {"admin", "not-the-password"},
"unknown user": {"nobody", testPassword},
} {
rec := postLogin(m, "203.0.113.7:5000", creds[0], creds[1])
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401", name, rec.Code)
}
if got := sessionCookieValue(t, m, rec); got != "" {
t.Errorf("%s: a session cookie was issued: %q", name, got)
}
bodies[name] = rec.Body.String()
}
if bodies["wrong password"] != bodies["unknown user"] {
t.Error("the two refusals differ, so the form tells an attacker which usernames exist")
}
}
// The lockout is what makes online guessing pointless, so it has to hold even
// for the request that finally carries the right password — and it has to be
// scoped to the address that spent the attempts.
func TestLoginLocksOutAfterTooManyAttempts(t *testing.T) {
m := moduleWithAdmin(t)
const attacker = "203.0.113.7:5000"
for i := 0; i < 10; i++ {
if rec := postLogin(m, attacker, "admin", "guess"); rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401 (still under the limit)", i+1, rec.Code)
}
}
rec := postLogin(m, attacker, "admin", testPassword)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429; the lockout was bypassed by guessing right", rec.Code)
}
if got := sessionCookieValue(t, m, rec); got != "" {
t.Errorf("a locked-out request was signed in: %q", got)
}
if rec := postLogin(m, "198.51.100.9:5000", "admin", testPassword); rec.Code != http.StatusSeeOther {
t.Errorf("another address got %d; one guesser locked out the whole internet", rec.Code)
}
}
// Before the first administrator exists there is nothing to sign in as, so the
// form is replaced by a pointer to the setup link rather than a password box
// that can never succeed.
func TestLoginPointsAtSetupBeforeTheFirstAdministrator(t *testing.T) {
m := testModule(t, false)
rec := httptest.NewRecorder()
m.HandleLogin(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "No administrator has been created yet") {
t.Errorf("the login page does not point at the setup link:\n%s", body)
}
if strings.Contains(body, `name="password"`) {
t.Errorf("the login page offers a password field with no account to use it:\n%s", body)
}
}
// getSetup performs the GET the operator's browser makes when it follows the
// one-time link.
func getSetup(m *Module, token string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
m.HandleSetup(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/setup/"+token, nil))
return rec
}
func postSetup(m *Module, token string, form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/setup/"+token,
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
m.HandleSetup(rec, r)
return rec
}
func setupForm(username, password, confirm string) url.Values {
return url.Values{
"username": {username},
"password": {password},
"password_confirm": {confirm},
}
}
// The setup link creates the first global administrator and then stops
// existing: the persistent fact is the user row, so the link is dead after a
// restart too, not only for the process that served it.
func TestSetupCreatesTheFirstAdministratorAndThenCloses(t *testing.T) {
m := testModule(t, false)
token, ok := m.setup.activeToken()
if !ok {
t.Fatal("no setup token on a panel with no users")
}
if rec := getSetup(m, token); rec.Code != http.StatusOK {
t.Fatalf("GET the setup link = %d, want the form", rec.Code)
}
rec := postSetup(m, token, setupForm("operator", "a-long-enough-password", "a-long-enough-password"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
}
u, err := m.store.GetUserByUsername("operator")
if err != nil {
t.Fatalf("the administrator was not created: %v", err)
}
if u.Role != RoleGlobal {
t.Errorf("the first administrator has role %q, want global", u.Role)
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("a-long-enough-password")); err != nil {
t.Errorf("the stored hash does not match the password that was set: %v", err)
}
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
t.Errorf("the setup link still answers %d after setup completed, want 404", rec.Code)
}
if rec := postSetup(m, token, setupForm("second", "a-long-enough-password", "a-long-enough-password")); rec.Code != http.StatusNotFound {
t.Errorf("a second administrator could be created through the setup link (%d)", rec.Code)
}
}
// A token that is wrong, or one that has aged out and been replaced, is not a
// hint that setup exists: both answer 404, the same as any unknown path.
func TestSetupRejectsAWrongOrExpiredToken(t *testing.T) {
m := testModule(t, false)
token, ok := m.setup.activeToken()
if !ok {
t.Fatal("no setup token on a panel with no users")
}
if rec := getSetup(m, token+"x"); rec.Code != http.StatusNotFound {
t.Errorf("a wrong token answered %d, want 404", rec.Code)
}
expireSetupToken(m)
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
t.Errorf("the expired token still opens setup (%d)", rec.Code)
}
fresh, _ := m.setup.activeToken()
if fresh == token {
t.Fatal("the expired token was not replaced")
}
if rec := getSetup(m, fresh); rec.Code != http.StatusOK {
t.Errorf("the reissued token does not open setup (%d)", rec.Code)
}
}
// The first account is the one that can never be locked out of the panel from
// outside, so the rules that apply to every other user apply here too — before
// anything is written.
func TestSetupRejectsCredentialsItWouldNotAcceptLater(t *testing.T) {
for name, form := range map[string]url.Values{
"username too short": setupForm("op", "a-long-enough-password", "a-long-enough-password"),
"username not ASCII": setupForm("оператор", "a-long-enough-password", "a-long-enough-password"),
"passwords differ": setupForm("operator", "a-long-enough-password", "a-long-enough-passwerd"),
"password too short": setupForm("operator", "short", "short"),
"no password at all": setupForm("operator", "", ""),
"no username at all": setupForm("", "a-long-enough-password", "a-long-enough-password"),
} {
m := testModule(t, false)
token, _ := m.setup.activeToken()
rec := postSetup(m, token, form)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", name, rec.Code)
}
if exists, err := m.store.UserExists(); err != nil || exists {
t.Errorf("%s: an administrator was created anyway (err=%v)", name, err)
}
if rec := getSetup(m, token); rec.Code != http.StatusOK {
t.Errorf("%s: the setup link was burned by a rejected form (%d)", name, rec.Code)
}
}
}
// Setup is unauthenticated by definition, so the only thing between the token
// and an offline guesser is the limiter in front of it.
func TestSetupIsRateLimited(t *testing.T) {
m := testModule(t, false)
for i := 0; i < 10; i++ {
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusNotFound {
t.Fatalf("attempt %d: status = %d, want 404 (still under the limit)", i+1, rec.Code)
}
}
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 after the eleventh attempt", rec.Code)
}
}
// expireSetupToken ages the current token out, the state the panel reaches when
// nobody follows the link within setupTokenTTL.
func expireSetupToken(m *Module) {
m.setup.mu.Lock()
defer m.setup.mu.Unlock()
m.setup.expiresAt = m.setup.expiresAt.Add(-2 * setupTokenTTL)
}
-12
View File
@@ -44,15 +44,3 @@ func CurrentUser(r *http.Request) string {
} }
return "" return ""
} }
// RequireGlobal wraps a handler that only global administrators may reach.
func RequireGlobal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := CurrentPrincipal(r.Context())
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
+43
View File
@@ -5,11 +5,14 @@ import (
"time" "time"
) )
const defaultMaxBuckets = 4096
// rateLimiter is a simple fixed-window per-key counter used to throttle the // rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs. // setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct { type rateLimiter struct {
max int max int
window time.Duration window time.Duration
maxBuckets int
mu sync.Mutex mu sync.Mutex
buckets map[string]*rlBucket buckets map[string]*rlBucket
@@ -24,10 +27,23 @@ func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{ return &rateLimiter{
max: max, max: max,
window: window, window: window,
maxBuckets: defaultMaxBuckets,
buckets: make(map[string]*rlBucket), buckets: make(map[string]*rlBucket),
} }
} }
func (r *rateLimiter) startSweeper() {
go func() {
ticker := time.NewTicker(r.window)
defer ticker.Stop()
for range ticker.C {
r.mu.Lock()
r.sweep(time.Now())
r.mu.Unlock()
}
}()
}
func (r *rateLimiter) Allow(key string) bool { func (r *rateLimiter) Allow(key string) bool {
now := time.Now() now := time.Now()
r.mu.Lock() r.mu.Lock()
@@ -35,6 +51,7 @@ func (r *rateLimiter) Allow(key string) bool {
b := r.buckets[key] b := r.buckets[key]
if b == nil || now.After(b.windowEnds) { if b == nil || now.After(b.windowEnds) {
r.makeRoom(now)
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)} r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
r.sweep(now) r.sweep(now)
return true return true
@@ -46,6 +63,32 @@ func (r *rateLimiter) Allow(key string) bool {
return true return true
} }
func (r *rateLimiter) makeRoom(now time.Time) {
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
return
}
r.sweep(now)
for len(r.buckets) >= r.maxBuckets {
r.evictOldest()
}
}
func (r *rateLimiter) evictOldest() {
var oldestKey string
var oldestEnds time.Time
first := true
for k, b := range r.buckets {
if first || b.windowEnds.Before(oldestEnds) {
oldestKey = k
oldestEnds = b.windowEnds
first = false
}
}
if oldestKey != "" {
delete(r.buckets, oldestKey)
}
}
func (r *rateLimiter) sweep(now time.Time) { func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets { for k, b := range r.buckets {
if now.After(b.windowEnds) { if now.After(b.windowEnds) {
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"testing"
"time"
)
// The limiter is what stands between the public login form and an unlimited
// guessing rate (security.md), so the ceiling has to be exact: the configured
// number of attempts go through and the next one does not, however often it is
// repeated.
func TestRateLimiterStopsAtTheCeiling(t *testing.T) {
r := newRateLimiter(3, time.Minute)
for i := 1; i <= 3; i++ {
if !r.Allow("203.0.113.7") {
t.Fatalf("attempt %d of 3 was refused before the ceiling", i)
}
}
for i := 4; i <= 6; i++ {
if r.Allow("203.0.113.7") {
t.Fatalf("attempt %d passed after the ceiling of 3", i)
}
}
}
// Buckets are per key, so one locked-out address must not lock out the rest of
// the internet — a shared counter would turn a single guesser into a denial of
// service against every operator.
func TestRateLimiterKeepsKeysApart(t *testing.T) {
r := newRateLimiter(1, time.Minute)
if !r.Allow("203.0.113.7") || r.Allow("203.0.113.7") {
t.Fatal("the first key did not use up its single attempt")
}
if !r.Allow("198.51.100.9") {
t.Fatal("a second address was refused because another one was locked out")
}
}
// The window is fixed, not sliding: once it has elapsed the count starts again
// from zero rather than being carried over. Time is moved by ageing the bucket
// instead of sleeping, so the test states the boundary rather than approaching
// it.
func TestRateLimiterReopensAfterTheWindow(t *testing.T) {
r := newRateLimiter(2, time.Minute)
r.Allow("203.0.113.7")
r.Allow("203.0.113.7")
if r.Allow("203.0.113.7") {
t.Fatal("the ceiling was not reached")
}
expire(r, "203.0.113.7")
if !r.Allow("203.0.113.7") {
t.Fatal("the key is still locked out after its window ended")
}
if !r.Allow("203.0.113.7") {
t.Fatal("the new window did not start from an empty count")
}
if r.Allow("203.0.113.7") {
t.Fatal("the new window allowed more than the ceiling")
}
}
// Every address that ever tried to sign in gets a bucket, and the only thing
// that removes the finished ones is the sweep on a new window. It runs on the
// key that triggered it as well as on the others, so a long-running panel does
// not accumulate a bucket per source address for ever.
func TestRateLimiterSweepsFinishedBuckets(t *testing.T) {
r := newRateLimiter(2, time.Minute)
for _, key := range []string{"203.0.113.7", "198.51.100.9"} {
r.Allow(key)
expire(r, key)
}
r.Allow("192.0.2.5") // still inside its window
r.Allow("203.0.113.7") // new window for this key: sweeps the rest
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.buckets["198.51.100.9"]; ok {
t.Error("a bucket whose window ended is still held")
}
for _, key := range []string{"203.0.113.7", "192.0.2.5"} {
if _, ok := r.buckets[key]; !ok {
t.Errorf("the sweep dropped %s, whose window is still open", key)
}
}
}
// expire moves a key's window into the past, the same state it would reach by
// waiting for the window to elapse.
func expire(r *rateLimiter, key string) {
r.mu.Lock()
defer r.mu.Unlock()
if b := r.buckets[key]; b != nil {
b.windowEnds = time.Now().Add(-time.Second)
}
}
// A long-running panel can see many unique client addresses. Finished buckets
// are swept on every new window, and a hard cap evicts the oldest when the map
// would otherwise grow without bound.
func TestRateLimiterCapsBucketCount(t *testing.T) {
r := newRateLimiter(1, time.Minute)
r.maxBuckets = 3
for i, key := range []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"} {
if !r.Allow(key) {
t.Fatalf("attempt %d for %s was refused under the cap", i+1, key)
}
expire(r, key)
}
if !r.Allow("203.0.113.8") {
t.Fatal("a fourth address was refused even though room was made")
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buckets) > 3 {
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
}
}
+9 -4
View File
@@ -41,17 +41,22 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// Create issues a new session for username and returns its token. // Create issues a new session for username and returns its token. It fails
func (s *sessionStore) Create(username string) string { // closed: if the row cannot be written the caller gets an error and must not
// hand out a cookie, because a token that is not in the database looks like a
// signed-in browser while every request it makes bounces back to /login.
func (s *sessionStore) Create(username string) (string, error) {
token := randomToken(32) token := randomToken(32)
now := time.Now() now := time.Now()
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil { if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
logf("panel: session: create failed: %v", err) return "", err
} }
// Pruning is housekeeping: the new session is already valid, so a failure
// here is logged and does not fail the login.
if _, err := s.store.DeleteExpiredSessions(now); err != nil { if _, err := s.store.DeleteExpiredSessions(now); err != nil {
logf("panel: session: prune expired failed: %v", err) logf("panel: session: prune expired failed: %v", err)
} }
return token return token, nil
} }
// Lookup returns the session username for a token if it exists and is // Lookup returns the session username for a token if it exists and is
+3 -2
View File
@@ -16,8 +16,9 @@ const setupTokenTTL = 10 * time.Minute
// setupManager owns the one-time administrator setup token. The token itself is // setupManager owns the one-time administrator setup token. The token itself is
// ephemeral (regenerated on restart or expiry) and lives only in memory; the // ephemeral (regenerated on restart or expiry) and lives only in memory; the
// persistent "setup complete" fact is the presence of the admin row in the // persistent "setup complete" fact is the presence of any row in the store's
// store, so once that exists the token is gone for good (security.md). // users table (`store.UserExists`), so once the first global administrator is
// created the token is gone for good (security.md).
type setupManager struct { type setupManager struct {
store *store.Store store *store.Store
hostname string hostname string
+1 -11
View File
@@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
if p.IsGlobal() { if p.IsGlobal() {
return h.store.ListDomains() return h.store.ListDomains()
} }
all, err := h.store.ListDomains() return h.store.ListDomainsForUser(p.ID)
if err != nil {
return nil, err
}
var out []store.Domain
for _, d := range all {
if p.CanAccessDomain(d.ID) {
out = append(out, d)
}
}
return out, nil
} }
func domainNameSet(domains []store.Domain) map[string]bool { func domainNameSet(domains []store.Domain) map[string]bool {
+125
View File
@@ -0,0 +1,125 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mixeme/selfpost/internal/web/auth"
)
// route is one entry of the authenticated mux, named the way web.go registers
// it so a route added there without a guard is visible as a missing case here.
type route struct {
method string
target string
handler func(*Handlers) http.HandlerFunc
// pathValues are the {id}-style segments the router would have bound.
pathValues map[string]string
}
// globalOnlyRoutes is every page and action that only a global administrator
// may reach: the panel's users, the whole-server backup and domain import, the
// machine-wide status and log views, and the domain lifecycle. A domain
// administrator is answered 404 rather than 403 so the panel does not confirm
// that the page exists (security.md).
var globalOnlyRoutes = []route{
{"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
{"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
{"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
{"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
{"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
{"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
{"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
{"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
{"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
{"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
{"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
{"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
{"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
{"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
{"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
{"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
{"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
{"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
}
// A domain administrator has an account on the panel, so authentication is not
// what keeps them off these pages — the per-handler role check is. Each of them
// is reached here with a valid session for a principal that owns a domain, the
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
h, domains := serverWithTwoDomains(t)
p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID)
for _, rt := range globalOnlyRoutes {
rec := call(h, rt, p)
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s",
rt.method, rt.target, rec.Code, rec.Body.String())
}
}
}
// The same 404 covers a request that carries no principal at all: the auth
// middleware normally redirects those, but a handler must not depend on
// middleware it cannot see for the role it enforces itself.
func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
h, _ := serverWithTwoDomains(t)
for _, rt := range globalOnlyRoutes {
rec := httptest.NewRecorder()
req := httptest.NewRequest(rt.method, rt.target, nil)
for k, v := range rt.pathValues {
req.SetPathValue(k, v)
}
rt.handler(h)(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code)
}
}
}
// The 404s above would also pass if a handler were broken into always returning
// one, so at least the two pages that need nothing but the store and the view
// have to be shown opening for a global administrator.
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
h, _ := serverWithTwoDomains(t)
for _, target := range []string{"/users", "/backup"} {
rt := getRoute(t, target)
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
target, rec.Code, rec.Body.String())
}
}
}
func getRoute(t *testing.T, target string) route {
t.Helper()
for _, rt := range globalOnlyRoutes {
if rt.method == http.MethodGet && rt.target == target {
return rt
}
}
t.Fatalf("no GET %s among the global-only routes", target)
return route{}
}
func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(rt.method, rt.target, nil)
req = auth.RequestWithPrincipal(req, p)
for k, v := range rt.pathValues {
req.SetPathValue(k, v)
}
rt.handler(h)(rec, req)
return rec
}
@@ -0,0 +1,46 @@
package handlers
import (
"regexp"
"strings"
"testing"
)
// TestE2ESendLogStatusMarkupDrift renders the real deliveries_rows fragment and
// checks whether the e2e gate's HTML scrapers still match it.
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
h, _ := serverWithDelivery(t)
rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows")
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
containsCell := func(html, needle string) bool {
return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
}
if statusCellPattern.FindStringSubmatch(rendered) == nil {
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
}
if !containsCell(rendered, "sent") {
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
}
}
func snippet(s, needle string) string {
i := strings.Index(s, needle)
if i < 0 {
if len(s) > 200 {
return s[:200] + "..."
}
return s
}
start := i - 20
if start < 0 {
start = 0
}
end := i + 120
if end > len(s) {
end = len(s)
}
return s[start:end]
}
+40 -30
View File
@@ -299,16 +299,22 @@ func deliveriesBackURL(r *http.Request) string {
// sendLogData reads the domain/app filters and page number off the query // sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs // string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination). // (filter dropdown options plus the current selection, rows, and pagination).
//
// The invariant this function owes the journal: a principal who is not global
// only ever reads rows for the domains assigned to them. That scope is stated
// to the store as SendLogFilter.Domains and holds for every number of
// assignments, including none — a domain administrator whose last domain was
// deleted gets an empty log, not the whole one. The query parameters are
// filters *within* that scope and can only narrow it: both are checked against
// the assigned domains and their applications before the query runs, because a
// dropdown that offers only permitted values is a courtesy to the browser, not
// a check on the request.
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
p, ok := h.principal(r) p, ok := h.principal(r)
if !ok { if !ok {
return nil, errors.New("no principal") return nil, errors.New("no principal")
} }
q := r.URL.Query() q := r.URL.Query()
filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
}
assigned, err := h.assignedDomains(p) assigned, err := h.assignedDomains(p)
if err != nil { if err != nil {
@@ -316,31 +322,6 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
} }
allowedNames := domainNameSet(assigned) allowedNames := domainNameSet(assigned)
if !p.IsGlobal() {
if filter.Domain != "" && !allowedNames[filter.Domain] {
filter.Domain = ""
}
if filter.Domain == "" && len(assigned) == 1 {
filter.Domain = assigned[0].Name
}
}
page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
view := make([]sendLogRow, len(rows))
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
}
domainNames := make([]string, 0, len(assigned)) domainNames := make([]string, 0, len(assigned))
for _, d := range assigned { for _, d := range assigned {
domainNames = append(domainNames, d.Name) domainNames = append(domainNames, d.Name)
@@ -362,9 +343,38 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
} }
sort.Strings(logins) sort.Strings(logins)
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] { filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
// A global administrator reads the whole journal, including rows left
// behind by a domain that has since been deleted.
Domains: domainNames,
AllDomains: p.IsGlobal(),
}
if !p.IsGlobal() {
if filter.Domain != "" && !allowedNames[filter.Domain] {
filter.Domain = ""
}
if filter.AppLogin != "" && !loginSet[filter.AppLogin] {
filter.AppLogin = "" filter.AppLogin = ""
} }
}
page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
view := make([]sendLogRow, len(rows))
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
}
lastPage := 1 lastPage := 1
if total > 0 { if total > 0 {
+180 -10
View File
@@ -129,7 +129,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("insert: %v", err) t.Fatalf("insert: %v", err)
} }
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0) rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 { if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows)) t.Fatalf("query: %v (%d rows)", err, len(rows))
} }
@@ -215,6 +215,174 @@ func TestDeliveryPageNotFound(t *testing.T) {
} }
} }
// A domain administrator reads the journal of the domains assigned to them and
// nothing else. The list used to be scoped only when exactly one domain was
// assigned, which meant two assignments read as none at all.
func TestSendLogScopedToAssignedDomains(t *testing.T) {
h, domains := serverWithTwoDomains(t)
for name, tc := range map[string]struct {
username string
domainIDs []int64
want []string
unwanted []string
}{
"global sees both": {
"", nil, []string{"First message", "Second message"}, nil,
},
"one assigned domain": {
"one-domain", []int64{domains["first.example.ru"].ID},
[]string{"First message"}, []string{"Second message", "second-app"},
},
"two assigned domains": {
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
[]string{"First message", "Second message"}, nil,
},
// Every assigned domain deleted cascades the assignments away. That
// leaves a principal entitled to nothing, which is an empty log — the
// case that used to hand over the whole journal.
"no assigned domains": {
"no-domains", nil, []string{"No messages logged yet."},
[]string{"First message", "Second message"},
},
} {
var p auth.Principal
if tc.username == "" {
p = globalPrincipal
} else {
p = domainAdmin(t, h.store, tc.username, tc.domainIDs...)
}
for view, handler := range map[string]http.HandlerFunc{
"page": h.HandleDeliveries,
"fragment": h.HandleDeliveriesRows,
} {
out := getBodyAs(t, handler, "/deliveries", p)
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
}
}
for _, unwanted := range tc.unwanted {
if strings.Contains(out, unwanted) {
t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out)
}
}
}
}
}
// The filter dropdowns offer only permitted values, so a leak through them can
// only come from a hand-written URL — which is exactly why the values are
// checked against the principal's own domains and applications rather than
// trusted for having been rendered by us.
func TestSendLogIgnoresForgedFilters(t *testing.T) {
h, domains := serverWithTwoDomains(t)
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
for _, target := range []string{
"/deliveries?domain=second.example.ru",
"/deliveries?app=second-app",
"/deliveries?domain=second.example.ru&app=second-app",
} {
out := getBodyAs(t, h.HandleDeliveries, target, p)
if strings.Contains(out, "Second message") {
t.Errorf("GET %s leaks another domain's journal:\n%s", target, out)
}
if !strings.Contains(out, "First message") {
t.Errorf("GET %s hid the principal's own journal:\n%s", target, out)
}
}
}
// The detail page has always checked membership; keep it checked, because the
// list and the page are two ways to the same row.
func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
h, domains := serverWithTwoDomains(t)
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
req.SetPathValue("id", itoa(rows[0].ID))
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
}
}
// serverWithTwoDomains builds a panel over a store holding two domains, one
// application and one delivered message each, so a scoping test can tell "my
// rows" from "every row" by reading the page.
func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
domains := make(map[string]store.Domain, 2)
for _, d := range []struct{ name, app, subject string }{
{"first.example.ru", "first-app", "First message"},
{"second.example.ru", "second-app", "Second message"},
} {
dom, err := st.AddDomain(d.name, "mail")
if err != nil {
t.Fatalf("add domain %s: %v", d.name, err)
}
if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application %s: %v", d.app, err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app,
From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject,
}); err != nil {
t.Fatalf("insert %s: %v", d.subject, err)
}
domains[d.name] = dom
}
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
}
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
t.Helper()
const hash = "test-hash"
if len(domainIDs) == 0 {
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
if err != nil {
t.Fatalf("add placeholder domain: %v", err)
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
if err := st.DeleteDomain(placeholder.ID); err != nil {
t.Fatalf("delete placeholder domain: %v", err)
}
domainIDs = nil
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
// serverWithDelivery builds a panel over a store holding one delivery, written // serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself. // the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) { func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
@@ -238,7 +406,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil { if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
t.Fatalf("update status: %v", err) t.Fatalf("update status: %v", err)
} }
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0) rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 { if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows)) t.Fatalf("query: %v (%d rows)", err, len(rows))
} }
@@ -246,18 +414,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0] return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
} }
// getBody runs one handler over a GET and returns the page it wrote, failing // getBody runs one handler over a GET as the global administrator.
// the test on any non-200. The path's {id} is bound by hand because these calls
// bypass the router that would otherwise fill it in.
func getBody(t *testing.T, h http.HandlerFunc, target string) string { func getBody(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper()
return getBodyAs(t, h, target, globalPrincipal)
}
// getBodyAs runs one handler over a GET as the given principal and returns the
// page it wrote, failing the test on any non-200. The path's {id} is bound by
// hand because these calls bypass the router that would otherwise fill it in.
func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string {
t.Helper() t.Helper()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil) req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, auth.Principal{ req = auth.RequestWithPrincipal(req, p)
ID: 1,
Username: "admin",
Role: auth.RoleGlobal,
})
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" { if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest) req.SetPathValue("id", rest)
} }
@@ -14,8 +14,8 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// HandleAccount serves the signed-in user's account settings. // HandleSettings serves the signed-in user's panel settings.
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
p, ok := h.principal(r) p, ok := h.principal(r)
@@ -25,20 +25,20 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
} }
u, err := h.store.GetUser(p.ID) u, err := h.store.GetUser(p.ID)
if err != nil { if err != nil {
logf("panel: account: get user failed: %v", err) logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
case http.MethodPost: case http.MethodPost:
h.submitAccount(w, r) h.submitSettings(w, r)
default: default:
w.Header().Set("Allow", "GET, POST") w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }
} }
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) { func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
var reportAuth dnscheck.Result var reportAuth dnscheck.Result
if showDMARC && formDMARCEmail != "" { if showDMARC && formDMARCEmail != "" {
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
@@ -58,13 +58,13 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
data["ReportAuthDNS"] = reportAuth data["ReportAuthDNS"] = reportAuth
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail) data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["Error"] = formErr data["Error"] = formErr
data["Flash"] = accountFlash(r) data["Flash"] = settingsFlash(r)
data["L1Messages"] = h.l1Messages() data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window() data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data) h.view.Render(w, status, "settings", data)
} }
func accountFlash(r *http.Request) string { func settingsFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") { switch r.URL.Query().Get("updated") {
case "username": case "username":
return "Username changed." return "Username changed."
@@ -85,16 +85,16 @@ func accountFlash(r *http.Request) string {
} }
} }
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) { if !h.auth.AllowLoginAttempt(r) {
p, _ := h.principal(r) p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusTooManyRequests, h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal()) "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
p, _ := h.principal(r) p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
return return
} }
@@ -105,7 +105,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
user, err := h.store.GetUser(p.ID) user, err := h.store.GetUser(p.ID)
if err != nil { if err != nil {
logf("panel: account: get user failed: %v", err) logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
@@ -123,21 +123,21 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
return return
} }
renaming := username != user.Username renaming := username != user.Username
if renaming { if renaming {
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return return
} }
} }
if p.IsGlobal() { if p.IsGlobal() {
if err := validate.Email(dmarcEmail); err != nil { if err := validate.Email(dmarcEmail); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
return return
} }
} }
@@ -147,16 +147,16 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
repassword := password != "" || confirm != "" repassword := password != "" || confirm != ""
if repassword { if repassword {
if password != confirm { if password != confirm {
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
return return
} }
if err := validate.AdminPassword(password); err != nil { if err := validate.AdminPassword(password); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return return
} }
} }
if !renaming && !repassword && !emailChanging { if !renaming && !repassword && !emailChanging {
h.renderAccount(w, r, http.StatusBadRequest, h.renderSettings(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal()) "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
return return
} }
@@ -165,8 +165,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
if repassword { if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
logf("panel: account: hashing password failed: %v", err) logf("panel: settings: hashing password failed: %v", err)
h.renderAccount(w, r, http.StatusInternalServerError, h.renderSettings(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal()) "Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
return return
} }
@@ -174,17 +174,17 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil { if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
logf("panel: account: update user failed: %v", err) logf("panel: settings: update user failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again." msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrUserNotFound) { if errors.Is(err, store.ErrUserNotFound) {
msg = "There is no user account to update." msg = "There is no user account to update."
} }
if errors.Is(err, store.ErrUserExists) { if errors.Is(err, store.ErrUserExists) {
msg = "That username is already in use." msg = "That username is already in use."
h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
return return
} }
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
return return
} }
@@ -197,7 +197,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
} }
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging) logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
} }
+52 -4
View File
@@ -102,10 +102,6 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)}) h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return return
} }
if r.PostFormValue("action") == "delete" {
h.submitUserDelete(w, r, u)
return
}
h.submitUserUpdate(w, r, u) h.submitUserUpdate(w, r, u)
default: default:
w.Header().Set("Allow", "GET, POST") w.Header().Set("Allow", "GET, POST")
@@ -291,6 +287,58 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther) http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
} }
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
// mis-click on Delete cannot remove a user (P3, code-review.md).
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + u.Username
data["Active"] = "users"
data["TargetID"] = u.ID
data["TargetUsername"] = u.Username
h.view.Render(w, http.StatusOK, "user_delete", data)
}
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
// and returns to the user list.
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.submitUserDelete(w, r, u)
}
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) { func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := h.principal(r) p, ok := h.principal(r)
if !ok { if !ok {
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+44 -5
View File
@@ -245,6 +245,11 @@ form.inline { display: inline; margin: 0; }
main.page-login, main.page-setup { max-width: 24rem; } main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; } .card + .card { margin-top: 1.2rem; }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; } .flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
/* RateLimitErr renders as .flash.error: a validation failure, not a success
notice. Without this rule it inherited the success surface (green) and only
.error's text colour, so a rejected rate limit read as good news in red
text. Same box, danger palette. */
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
/* A table column is at least as wide as the longest unbreakable run inside it, /* A table column is at least as wide as the longest unbreakable run inside it,
and the panel's tables are full of runs with nothing to break on: email and the panel's tables are full of runs with nothing to break on: email
@@ -360,6 +365,19 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
.app .actions > .panel .panel-buttons form { margin-top: 0; } .app .actions > .panel .panel-buttons form { margin-top: 0; }
.app .actions > .panel .check-cols { margin-top: 0.6rem; } .app .actions > .panel .check-cols { margin-top: 0.6rem; }
.app .actions > .panel .check-col > form { margin-top: 0; } .app .actions > .panel .check-col > form { margin-top: 0; }
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
.app .actions > .panel .check-col > .muted { margin: 0; }
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
margin-top: 0.45rem;
min-height: 2.55rem;
box-sizing: border-box;
}
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
field-sizing: fixed;
overflow-y: auto;
}
.credential { border-color: var(--credential-border); background: var(--credential-bg); } .credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every /* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */ authenticated page without each content template having to include it. */
@@ -561,16 +579,37 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
} }
.card > h2 + .check-col-title { margin-top: 0.55rem; } .card > h2 + .check-col-title { margin-top: 0.55rem; }
/* Domain settings pairs DMARC rate limit: share four row tracks so titles,
leads, fields and action buttons line up across columns. */
@supports (grid-template-rows: subgrid) {
.check-cols-rows {
grid-template-rows: auto auto 1fr auto;
}
.check-cols-rows > .check-col {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
}
}
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
.check-cols-rows > .check-col > .muted { margin: 0; }
.check-col-fields { min-width: 0; }
.check-col-fields > form { margin: 0; }
.check-col-fields .field-pair { margin-top: 0; }
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
.check-col-actions {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
margin-top: 0;
}
.check-col-actions > button { margin-top: 1.2rem; }
.check-col-actions > form.inline { margin: 0; }
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
/* Two short fields on one row (rate-limit message count window). Falls to /* Two short fields on one row (rate-limit message count window). Falls to
one column when the parent is too narrow for both. */ one column when the parent is too narrow for both. */
.field-pair { .field-pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0 1rem; gap: 0 1rem; margin-top: 0.45rem;
} }
/* First control row in a check-col form: match a bare label's top margin so
paired columns (DMARC rate limit) line up their first field labels. */
form > .field-pair { margin-top: 0; }
form > .field-pair > div > label { margin-top: 0.9rem; }
.field-pair > div { min-width: 0; } .field-pair > div { min-width: 0; }
.field-pair label { margin-top: 0.45rem; } .field-pair label { margin-top: 0.45rem; }
/* Hint under a field-pair sits tight above the form's submit button (the /* Hint under a field-pair sits tight above the form's submit button (the
+58 -122
View File
@@ -46,145 +46,81 @@
} }
}); });
// --- Address list shown only in list mode ----------------------------- // --- Conditional field visibility ------------------------------------
// The "Addresses" field applies to list mode only; in wildcard mode the // Several forms hide a block until a select, checkbox or file input says it
// server ignores it, so hiding it removes a field that does nothing. The // applies. One rule table drives them all so the five near-identical helpers
// toggle runs on load too, because the edit form of an existing application // do not drift.
// may already be set to list mode. var showWhenRules = [
function syncAddressField(select) { {
var form = select.closest("form"); match: "select[data-list-mode]",
var field = form && form.querySelector("[data-addresses]"); target: "[data-addresses]",
if (!field) { visible: function (el) { return el.value === el.dataset.listMode; }
},
{
match: "select[data-custom-mode]",
target: "[data-custom-address]",
visible: function (el) { return el.value === el.dataset.customMode; }
},
{
match: "select[data-global-role]",
target: "[data-domain-pick]",
visible: function (el) { return el.value !== el.dataset.globalRole; }
},
{
match: "input[data-encrypt-toggle]",
target: "[data-encrypt-fields]",
visible: function (el) { return el.checked; },
clearWhenHidden: true
},
{
match: "input[data-import-file]",
target: "[data-import-password-fields]",
visible: function (el) {
var name = (el.files && el.files[0] && el.files[0].name || "").toLowerCase();
return name !== "" && !/\.json$/.test(name);
},
clearWhenHidden: true
}
];
function syncShowWhen(control) {
var form = control.closest("form");
if (!form) {
return; return;
} }
// The mode values come from the server (store.AddressModeList), so the for (var i = 0; i < showWhenRules.length; i++) {
// select carries the one that means "list" rather than this script var rule = showWhenRules[i];
// hard-coding it. if (!control.matches(rule.match)) {
field.hidden = select.value !== select.dataset.listMode; continue;
} }
var target = form.querySelector(rule.target);
function initAddressFields(root) { if (!target) {
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
syncAddressField(select);
select.addEventListener("change", function () {
syncAddressField(select);
});
});
}
// --- Custom DMARC rua address shown only for "custom" mode ------------
// Same idea as the address list: the email field only applies when the
// operator picks Custom address. With JavaScript blocked the field stays
// visible and the server still ignores it for inherit/none.
function syncCustomAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-custom-address]");
if (!field) {
return; return;
} }
field.hidden = select.value !== select.dataset.customMode; var show = rule.visible(control);
} target.hidden = !show;
if (!show && rule.clearWhenHidden) {
function initCustomAddressFields(root) { target.querySelectorAll("input").forEach(function (input) {
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
syncCustomAddressField(select);
select.addEventListener("change", function () {
syncCustomAddressField(select);
});
});
}
// --- Domain pick shown only for domain administrators ------------------
// Global administrators manage every domain, so the assignment checkboxes
// are irrelevant for that role. The toggle runs on load too, because the
// edit form of an existing global user should not flash the fieldset.
function syncDomainPickField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-domain-pick]");
if (!field) {
return;
}
field.hidden = select.value === select.dataset.globalRole;
}
function initDomainPickFields(root) {
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
syncDomainPickField(select);
select.addEventListener("change", function () {
syncDomainPickField(select);
});
});
}
// --- Encryption password fields shown only when asked for --------------
// The backup, export and import forms carry an optional password block. It
// is hidden until the checkbox next to it is ticked, and cleared when it is
// unticked, so a password typed and then abandoned is never submitted. With
// JavaScript blocked the block stays visible and the forms behave exactly as
// the server reads them: the checkbox alone decides whether encryption
// happens.
function syncEncryptFields(box) {
var form = box.closest("form");
var fields = form && form.querySelector("[data-encrypt-fields]");
if (!fields) {
return;
}
fields.hidden = !box.checked;
if (!box.checked) {
fields.querySelectorAll("input").forEach(function (input) {
input.value = ""; input.value = "";
}); });
} }
}
function initEncryptFields(root) {
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
syncEncryptFields(box);
box.addEventListener("change", function () {
syncEncryptFields(box);
});
});
}
// --- Import password field shown based on the chosen file's extension ---
// The domain-import file decides for itself whether it is encrypted (the
// server checks the envelope magic, not a checkbox), so the panel offers
// the password field the same way: reveal it for a .spde file, hide and
// clear it for a plain .json one. With no file chosen yet there is nothing
// to ask a password for, so the field stays hidden until a file names it.
// An unrecognised name leaves the field visible rather than guessing wrong
// and hiding a password the file needs.
function syncImportPasswordField(input) {
var form = input.closest("form");
var fields = form && form.querySelector("[data-import-password-fields]");
if (!fields) {
return; return;
} }
var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase();
var hide = name === "" || /\.json$/.test(name);
fields.hidden = hide;
if (hide) {
fields.querySelectorAll("input").forEach(function (pw) {
pw.value = "";
});
}
} }
function initImportPasswordField(root) { function initShowWhen(root) {
root.querySelectorAll("input[data-import-file]").forEach(function (input) { showWhenRules.forEach(function (rule) {
syncImportPasswordField(input); root.querySelectorAll(rule.match).forEach(function (control) {
input.addEventListener("change", function () { syncShowWhen(control);
syncImportPasswordField(input); control.addEventListener("change", function () {
syncShowWhen(control);
});
}); });
}); });
} }
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document); initShowWhen(document);
initCustomAddressFields(document);
initDomainPickFields(document);
initEncryptFields(document);
initImportPasswordField(document);
}); });
// --- Adaptive monitoring polling --------------------------------------- // --- Adaptive monitoring polling ---------------------------------------
+27
View File
@@ -1,8 +1,10 @@
package view package view
import ( import (
"io/fs"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -26,6 +28,7 @@ func TestStaticAssetsCarryETag(t *testing.T) {
// The fonts are the assets this matters most for: they are the largest // The fonts are the assets this matters most for: they are the largest
// thing the panel serves and the ones a browser is most willing to keep. // thing the panel serves and the ones a browser is most willing to keep.
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2", "ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
"OFL.txt",
} { } {
rec := serveStatic("/static/"+name, nil) rec := serveStatic("/static/"+name, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -77,3 +80,27 @@ func TestStaticETagsAreContentDerived(t *testing.T) {
t.Errorf("favicon.png and favicon.svg share the ETag %s", png) t.Errorf("favicon.png and favicon.svg share the ETag %s", png)
} }
} }
// OFL condition 2: the licence text must travel with the Font Software. The
// WOFF2 files are embedded; OFL.txt sits next to them so a copy of the panel
// (source tree, image, or /static/OFL.txt) always has it.
func TestOFLTravelsWithFonts(t *testing.T) {
b, err := fs.ReadFile(assetsFS, "static/OFL.txt")
if err != nil {
t.Fatalf("OFL.txt is not embedded next to the Plex WOFF2 files: %v", err)
}
body := string(b)
if !strings.Contains(body, `Reserved Font Name "Plex"`) {
t.Error("OFL.txt is missing the IBM Plex reserved-font-name notice")
}
if !strings.Contains(body, "SIL OPEN FONT LICENSE Version 1.1") {
t.Error("OFL.txt is missing the SIL OFL 1.1 text")
}
rec := serveStatic("/static/OFL.txt", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /static/OFL.txt: status %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "SIL OPEN FONT LICENSE Version 1.1") {
t.Error("GET /static/OFL.txt did not serve the OFL text")
}
}
@@ -1,5 +1,6 @@
{{define "deliveries_rows"}} {{define "deliveries_rows"}}
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}" <div id="deliveries-rows" data-poll aria-live="polite"
hx-get="/deliveries/rows?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML"> hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}} {{if .Rows}}
<table> <table>
@@ -14,15 +15,15 @@
<td>{{.To}}</td> <td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td> <td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td> <td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td> <td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
<p class="muted"> <p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}} Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}} {{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}} {{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p> </p>
{{else}} {{else}}
<p class="muted">No messages logged yet.</p> <p class="muted">No messages logged yet.</p>
+90 -142
View File
@@ -2,6 +2,61 @@
measure (same pattern as Status). */}} measure (same pattern as Status). */}}
{{define "wide"}}wide{{end}} {{define "wide"}}wide{{end}}
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
shows (DKIM, SPF, DMARC, report authorization) in both the status card
and the publishable-record cards below it — only the host and whether it
carries a Copy button change. Two variants rather than one templated
Copy flag: the DNS status card never offers Copy (its host is derived,
not something to paste), the record cards always do. */}}
{{define "host_type"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{define "host_type_copy"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{/* Value partials pair with the two host_type variants above: field_values
is the DNS status card's raw record dump (no Copy — those values are for
comparison, not for pasting), field_value is the single publishable
value on the record cards below (always has Copy). Callers still guard
the empty case, since "no records yet" and "one blank record" read
differently. */}}
{{define "field_values"}}
<label>Value</label>
<span class="code">{{range .}}{{.}}
{{end}}</span>
{{end}}
{{define "field_value"}}
<label>Value</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
{{define "content"}} {{define "content"}}
<h1>{{.Domain.Name}}</h1> <h1>{{.Domain.Name}}</h1>
@@ -37,21 +92,8 @@
<div class="check-cols"> <div class="check-cols">
<div class="check-col"> <div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label> <label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<div class="field-pair host-type"> {{template "host_type" .Record.Name}}
<div> {{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}}
<label>Host / name</label>
<span class="code">{{.Record.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DKIM.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>
{{end}}
{{if ne .DNS.DKIM.Status "ok"}} {{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p> <p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}} {{end}}
@@ -59,21 +101,8 @@
<div class="check-col"> <div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label> <label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<div class="field-pair host-type"> {{template "host_type" .Domain.Name}}
<div> {{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}}
<label>Host / name</label>
<span class="code">{{.Domain.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.SPF.Records}}
<label>Value</label>
<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>
{{end}}
{{if ne .DNS.SPF.Status "ok"}} {{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p> <p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}} {{end}}
@@ -83,21 +112,8 @@
<div class="check-col"> <div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label> <label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<div class="field-pair host-type"> {{template "host_type" .DMARCName}}
<div> {{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}}
<label>Host / name</label>
<span class="code">{{.DMARCName}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DMARC.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>
{{end}}
{{if ne .DNS.DMARC.Status "ok"}} {{if ne .DNS.DMARC.Status "ok"}}
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p> <p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{else}} {{else}}
@@ -108,21 +124,8 @@
<div class="check-col"> <div class="check-col">
{{if .DNS.DMARCReportAuth.Status}} {{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label> <label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<div class="field-pair host-type"> {{template "host_type" .ReportAuthName}}
<div> {{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}
<label>Host / name</label>
<span class="code">{{.ReportAuthName}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DMARCReportAuth.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>
{{end}}
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p> <p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{else}} {{else}}
<label>Report authorization</label> <label>Report authorization</label>
@@ -141,49 +144,15 @@
<h2>DKIM and SPF records</h2> <h2>DKIM and SPF records</h2>
<p class="check-col-title">DKIM</p> <p class="check-col-title">DKIM</p>
<div class="field-pair host-type"> {{template "host_type_copy" .Record.Name}}
<div> {{template "field_value" .Record.Value}}
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Record.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.Record.Value}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Not a secret. Signed with selector <p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p> <strong>{{.Domain.DKIMSelector}}</strong>.</p>
<p class="check-col-title">SPF</p> <p class="check-col-title">SPF</p>
<div class="field-pair host-type"> {{template "host_type_copy" .Domain.Name}}
<div> {{template "field_value" .SPFExample}}
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.SPFExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Merge into an existing SPF if the domain already has one — <p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p> do not publish a second record.</p>
@@ -192,19 +161,7 @@
<div class="card" id="dmarc"> <div class="card" id="dmarc">
<h2>DMARC record</h2> <h2>DMARC record</h2>
<div class="field-pair host-type"> {{template "host_type_copy" .DMARCName}}
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.DMARCName}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label> <label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row"> <div class="code-row">
@@ -214,31 +171,13 @@
{{if .SameDomainRUA}} {{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does <p class="error">The report address is on this sending domain. SelfPost does
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report not receive inbound mail — use a mailbox on another domain.</p>
reception in a future release.</p>
{{end}} {{end}}
{{if .NeedsReportAuth}} {{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p> <p class="check-col-title">Report authorization</p>
<div class="field-pair host-type"> {{template "host_type_copy" .ReportAuthName}}
<div> {{template "field_value" .ReportAuthValue}}
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}} {{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to <p class="muted"><code>p=none</code> does not affect delivery. Tighten to
@@ -329,6 +268,7 @@
<div class="check-cols"> <div class="check-cols">
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Address mode</p> <p class="check-col-title">Address mode</p>
<p class="muted">Which From addresses this application may use.</p>
<form method="post" action="/applications/{{.ID}}/mode"> <form method="post" action="/applications/{{.ID}}/mode">
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode"> <select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option> <option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
@@ -345,12 +285,14 @@
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Optional trusted-IP override <p class="check-col-title">Optional trusted-IP override
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p> {{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">One per line or comma-separated.</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit"> <form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Trusted client IPs (required; one per line or comma-separated)</label> <textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea> aria-label="Trusted client IPs">{{.IPsText}}</textarea>
<p class="muted">These IPs get a higher ceiling than the domain <p class="muted">These IPs get a higher ceiling than the domain
(≤ level&nbsp;1) and skip the domain check; everyone else uses (≤ level&nbsp;1) and skip the domain check; everyone else uses
{{if $.DomainHasRL}}the domain ceiling ({{$.DomainRLMaxNum}}){{else}}level&nbsp;1 only{{end}}.</p> the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
if set, otherwise level&nbsp;1.</p>
<div class="field-pair"> <div class="field-pair">
<div> <div>
<label>Message limit (max {{$.L1Messages}})</label> <label>Message limit (max {{$.L1Messages}})</label>
@@ -390,12 +332,13 @@
<div class="card" id="domain-settings"> <div class="card" id="domain-settings">
<h2>Domain settings</h2> <h2>Domain settings</h2>
<div class="check-cols"> <div class="check-cols check-cols-rows">
<div class="check-col"> <div class="check-col">
<p class="check-col-title">DMARC reports</p> <p class="check-col-title">DMARC reports</p>
<p class="muted">Default comes from <a href="/settings">Settings</a>; <p class="muted">Default comes from <a href="/settings">Settings</a>;
override per domain here.</p> override per domain here.</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc"> <div class="check-col-fields">
<form id="dmarc-domain" method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label> <label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom"> <select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option> <option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
@@ -409,17 +352,19 @@
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}" autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com"> placeholder="reports@your-mail-domain.com">
</div> </div>
<button type="submit">Save DMARC report settings</button>
</form> </form>
</div> </div>
<div class="check-col-actions">
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
</div>
</div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Level-2 rate limit <p class="check-col-title">Level-2 rate limit
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p> {{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p> <p class="muted">Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
<div class="check-col-fields">
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit"> <form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<div class="field-pair"> <div class="field-pair">
<div> <div>
<label for="d_max">Message limit (max {{.L1Messages}})</label> <label for="d_max">Message limit (max {{.L1Messages}})</label>
@@ -433,8 +378,10 @@
</div> </div>
<p class="muted">Applies to every client IP on this domain. Leave the <p class="muted">Applies to every client IP on this domain. Leave the
message limit empty to use level&nbsp;1 only.</p> message limit empty to use level&nbsp;1 only.</p>
<button type="submit">Save limit</button>
</form> </form>
</div>
<div class="check-col-actions">
<button type="submit" form="rl-domain">Save limit</button>
{{if .DomainHasRL}} {{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit" <form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."> data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
@@ -444,6 +391,7 @@
{{end}} {{end}}
</div> </div>
</div> </div>
</div>
</div> </div>
<div class="split"> <div class="split">
@@ -1,5 +1,5 @@
{{define "mail_queue_body"}} {{define "mail_queue_body"}}
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML"> <div id="mail-queue-body" data-poll aria-live="polite" hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span> <span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div> </div>
+23 -28
View File
@@ -2,6 +2,25 @@
in panel.css, as on a delivery's page). */}} in panel.css, as on a delivery's page). */}}
{{define "wide"}}wide{{end}} {{define "wide"}}wide{{end}}
{{/* The username/password fields are identical for a global administrator
(split card, DMARC alongside) and a domain administrator (narrow card,
no DMARC card) — only the surrounding form and card differ. */}}
{{define "credentials_fields"}}
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{end}}
{{define "content"}} {{define "content"}}
<h1>Settings</h1> <h1>Settings</h1>
@@ -16,27 +35,15 @@
<p class="muted">These are the credentials for this control panel only. <p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p> Applications keep their own logins and passwords, which are not affected.</p>
<label for="username">Username</label> {{template "credentials_fields" .}}
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
</div> </div>
<div class="card"> <div class="card">
<h2>DMARC aggregate reports</h2> <h2>DMARC aggregate reports</h2>
<p class="muted">Optional default <code>rua=</code> address for every sending <p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that domain (can be overridden per domain). Use a mailbox on a domain that
receives inbound mail. SelfPost is send-only today; a future release will receives inbound mail. SelfPost does not receive inbound mail — point
be able to receive reports in the panel itself.</p> <code>rua=</code> at a mailbox elsewhere.</p>
<label for="dmarc_report_email">Default report address</label> <label for="dmarc_report_email">Default report address</label>
<input id="dmarc_report_email" name="dmarc_report_email" type="email" <input id="dmarc_report_email" name="dmarc_report_email" type="email"
@@ -81,19 +88,7 @@ this one stays signed in.</p>
<p class="muted">These are the credentials for this control panel only. <p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p> Applications keep their own logins and passwords, which are not affected.</p>
<form method="post" action="/settings"> <form method="post" action="/settings">
<label for="username">Username</label> {{template "credentials_fields" .}}
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
<button type="submit">Save changes</button> <button type="submit">Save changes</button>
</form> </form>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "status_body"}} {{define "status_body"}}
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML"> <div id="status-body" data-poll aria-live="polite" hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="card" id="overall"> <div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2> <h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p> <p class="muted">{{.OverallHeading}}</p>
@@ -1,5 +1,5 @@
{{define "system_log_body"}} {{define "system_log_body"}}
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML"> <div id="system-log-body" data-poll aria-live="polite" hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}} <span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span> {{end}}{{else}}No log lines yet.{{end}}</span>
@@ -0,0 +1,16 @@
{{define "content"}}
<h1>Delete {{.TargetUsername}}</h1>
{{template "back_link" (back (printf "/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
<div class="card narrow">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>{{.TargetUsername}}</strong>.
This cannot be undone; a signed-in session for this user stops working
immediately.</p>
<form method="post" action="/users/{{.TargetID}}/delete">
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
</form>
</div>
{{end}}
+7 -4
View File
@@ -33,10 +33,13 @@
</fieldset> </fieldset>
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button> <button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
{{if .IsEdit}}
<button type="submit" name="action" value="delete" class="danger"{{if .LastGlobalLocked}} disabled{{end}}>Delete user</button>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be deleted.</p>{{end}}
{{end}}
</form> </form>
{{if .IsEdit}}
{{if .LastGlobalLocked}}
<p class="muted">The only global administrator cannot be deleted.</p>
{{else}}
<a class="danger" href="/users/{{.UserID}}/delete">Delete user</a>
{{end}}
{{end}}
</div> </div>
{{end}} {{end}}
+1
View File
@@ -282,6 +282,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{ drillDown := map[string]bool{
"user_form.html": true, "user_form.html": true,
"user_delete.html": true,
"domain_detail.html": true, "domain_detail.html": true,
"domain_delete.html": true, "domain_delete.html": true,
"delivery.html": true, "delivery.html": true,
+1
View File
@@ -37,6 +37,7 @@ var pageFiles = map[string][]string{
"settings": {"templates/settings.html"}, "settings": {"templates/settings.html"},
"users": {"templates/users.html"}, "users": {"templates/users.html"},
"user_form": {"templates/user_form.html"}, "user_form": {"templates/user_form.html"},
"user_delete": {"templates/user_delete.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"}, "backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
+4 -2
View File
@@ -1,4 +1,4 @@
// Package web implements the SelfPost control panel's HTTP surface: the // Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (security.md), login/session handling // one-time administrator setup flow (security.md), login/session handling
// (security.md) and the authenticated shell the later phases build on. // (security.md) and the authenticated shell the later phases build on.
package web package web
@@ -158,7 +158,7 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload) authed.HandleFunc("POST /reload", h.HandleReload)
authed.HandleFunc("/settings", h.HandleAccount) authed.HandleFunc("/settings", h.HandleSettings)
authed.HandleFunc("/account", redirectSettings) authed.HandleFunc("/account", redirectSettings)
authed.HandleFunc("GET /users", h.HandleUsers) authed.HandleFunc("GET /users", h.HandleUsers)
@@ -166,6 +166,8 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /users/new", h.HandleUserNew) authed.HandleFunc("POST /users/new", h.HandleUserNew)
authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit) authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit) authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("GET /users/{uid}/delete", h.HandleUserDeleteConfirm)
authed.HandleFunc("POST /users/{uid}/delete", h.HandleUserDelete)
authed.HandleFunc("GET /backup", h.HandleBackupPage) authed.HandleFunc("GET /backup", h.HandleBackupPage)
authed.HandleFunc("POST /backup", h.HandleBackup) authed.HandleFunc("POST /backup", h.HandleBackup)
+6 -1
View File
@@ -60,8 +60,13 @@ services:
# records the test harness publishes, so Postfix's outbound MX lookup for # records the test harness publishes, so Postfix's outbound MX lookup for
# the sink and the panel's own dnscheck resolve inside the isolated network # the sink and the panel's own dnscheck resolve inside the isolated network
# instead of hitting the real internet. # instead of hitting the real internet.
#
# Pinned to a release tag rather than `latest`, so a CoreDNS upload cannot
# change what the release gate runs against between two runs of the same
# commit. Not a digest: the tag is a multi-arch manifest and the stand has to
# come up on arm64 developer machines as well as amd64 CI.
coredns: coredns:
image: coredns/coredns:latest image: coredns/coredns:1.14.6
command: ["-conf", "/dns/Corefile"] command: ["-conf", "/dns/Corefile"]
volumes: volumes:
- ./dns-stage:/dns - ./dns-stage:/dns
+2 -1
View File
@@ -10,7 +10,8 @@ import (
) )
// checkLogrotateConfigMode verifies the image pins /etc/logrotate.d/mail at 0644 // checkLogrotateConfigMode verifies the image pins /etc/logrotate.d/mail at 0644
// so logrotate will not silently ignore it (docs/plans/logrotate-mode.md). // so logrotate will not silently ignore it (docs/development.md § Building
// binaries and the image).
func checkLogrotateConfigMode(s *stack) error { func checkLogrotateConfigMode(s *stack) error {
mode, err := s.execIn("selfpost", "stat", "-c", "%a", "/etc/logrotate.d/mail") mode, err := s.execIn("selfpost", "stat", "-c", "%a", "/etc/logrotate.d/mail")
if err != nil { if err != nil {
+2 -2
View File
@@ -19,7 +19,7 @@ import (
"time" "time"
) )
var statusCellPattern = regexp.MustCompile(`<td>(queued|sent|deferred|bounced|rejected)</td>`) var statusCellPattern = regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
// h is the single shared stand for the whole ordered scenario in TestE2E. // h is the single shared stand for the whole ordered scenario in TestE2E.
// TestHostnameGate does not use it — it spins its own disposable container. // TestHostnameGate does not use it — it spins its own disposable container.
@@ -247,7 +247,7 @@ func uniqueToken(label string) string {
} }
func containsCell(html, needle string) bool { func containsCell(html, needle string) bool {
return strings.Contains(html, "<td>"+needle+"</td>") return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
} }
func firstStatusCell(html string) string { func firstStatusCell(html string) string {
+1 -1
View File
@@ -219,5 +219,5 @@ func testLevel1RateLimit(t *testing.T, sc *scenario) {
return return
} }
} }
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=5) never tripped after %d sends", maxAttempts) t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
} }