96 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
mix 958d07fcdd panel: polish rate-limit form copy and field alignment
test / test (push) Has been cancelled
Drop the app L1 backstop line, move trusted-IP help under the IP field,
shorten the domain rate-limit title, and align field-pair labels with DMARC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:26:17 +03:00
mix f0cd804cb3 panel: tighten rate-limit and address-mode copy
Restore muted L1 backstop line, move domain limit help above Save, and drop
the duplicate Address mode field label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:13:34 +03:00
mix 3950bd333b docs: put rate-limit UI polish back under Unreleased
test / test (push) Has been cancelled
1.2.4 stays as cut at the release commit; the L1 display and badge work
belongs in Unreleased until the next version cut (development.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:40:33 +03:00
mix 584f8cbc1c panel: clearer L1 rate-limit display and status badges
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Show the level-1 backstop as its own line and code-row, use st badges for
active/inactive domain and app limits, and fold the polish into 1.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:37:12 +03:00
mix 6671bd3393 release: 1.2.4
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Close Unreleased for the level-2 rate-limit semantics change and DNS Type
field height fix; pin compose and docs to 1.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:21:28 +03:00
mix 00e36df553 rate limit: domain ceiling for all IPs, trusted app override
Invert level-2 semantics so domain limits apply to every client IP and
application limits with trusted IPs raise the ceiling above the domain
(still capped by level 1). Panel shows L1, validates maxima, and documents
the model on Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:19:51 +03:00
mix b0ebe061b5 panel: match DNS Type TXT height to Host fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:06:09 +03:00
mix 601e183e0c release: 1.2.3
Close CHANGELOG [Unreleased] as 1.2.3; bump the compose pin and image references. Includes domain detail layout polish and Domains add-row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:52:19 +03:00
mix f9e259a66d panel: refine domain detail layout and drop section index
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:16:03 +03:00
mix 44e79c4120 panel: pair domain detail cards like Status (SPF | DMARC)
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:33:41 +03:00
mix 1d30605533 panel: align page URLs, titles, and headings
test / test (push) Has been cancelled
Settings moves to /settings with a 308 redirect from /account; domains, Status, Users, and user forms get matching browser titles and h1 text; backup page title reflects domain import.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:24:14 +03:00
mix b87baa6dd6 fix: pin logrotate config mode in image and fail loud on bad permissions
test / test (push) Has been cancelled
COPY --chmod makes /etc/logrotate.d/mail 0644 regardless of build context
file modes (Windows tar sync). logrotate-loop preflight exits non-zero when
logrotate would ignore the config. E2e covers mode, forced rotation, and a
group-writable context build.

Co-Authored-By: Composer 2.5 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:51:37 +03:00
mix 9420e30d6f release: 1.2.2
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.2; bump the compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:34:09 +03:00
mix 93cf1de3b7 panel: denser Status layout with true two-column cards
test / test (push) Has been cancelled
Pair Machine|Processes, queue|cert, and sockets|hostname; fix .split
auto-margins so cards fill half the row; trim Status prose and machine
details; drop the page section index; note panel-docs on the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:29:34 +03:00
mix 6b8658602d panel: place mail queue and TLS cert side by side on Status
test / test (push) Has been cancelled
Widen the status page and wrap the short queue/certificate cards in
.split so the stack is shorter; other checks stay full width.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:24:24 +03:00
mix 3a520d9ec7 panel: fix Assigned domains checkbox layout on user form
test / test (push) Has been cancelled
Form-wide block labels and full-width inputs stacked the box above the
domain name; checkbox rows now share label.check and fieldset spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:15:37 +03:00
mix 4015ecd70d release: 1.2.1
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.1; bump the compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:03:54 +03:00
mix ad014ecdc1 panel: centre status badge text optically
test / test (push) Has been cancelled
IBM Plex Mono sits low in its em square; top-heavy badge padding left
ok/warn sunk below the adjacent heading. Bottom padding is now heavier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:58:45 +03:00
mix de6f7a88da panel: disable demote and delete for the only global admin
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:42:01 +03:00
mix 5985977f21 panel: hide domain pick for global administrators on user form
test / test (push) Has been cancelled
Global administrators manage every domain; the assignment checkboxes are
now hidden when that role is selected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:37:04 +03:00
mix 9ff7514621 panel: tidy the Users nav icon
test / test (push) Has been cancelled
Two full silhouettes with staggered baselines read lopsided at 16 px; the
rear person is now a right-side crescent behind an aligned front silhouette.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:31:07 +03:00
mix ae7f5258a6 panel: use a gear for the Settings nav icon
The icon was a sun-with-rays (circle plus spokes), not a cog, despite the
1.2.0 note that Settings used a gear.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 22:13:29 +03:00
mix 5bd632c8e0 panel: place drill-down back links under the heading
test / test (push) Has been cancelled
The user form had Back to users at the bottom of the card; all drill-down
pages now use a shared back_link template with a structural test.

Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:58:08 +03:00
mix f1074eb526 panel: show Settings credentials and DMARC side by side
test / test (push) Has been cancelled
Global administrators get two cards in the .split layout; domain-scoped
users keep the single narrow form.

Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:40:59 +03:00
mix bc005dea85 release: 1.2.0
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.0; correct missing entries; bump the
compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:39:11 +03:00
mix 4b2f3880ef panel: align heading with narrow cards on signed-in pages
Settings and the user form centred the title on the 48rem measure while
.card.narrow centred on 24rem, so their left edges drifted by 12rem. Cap every
direct child of main when a narrow card is present, without shrinking the
column, so navigation stays put and login/setup stay unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 17:34:04 +03:00
mix fae930a37a panel: give Settings, Users, and the session user distinct nav icons
test / test (push) Has been cancelled
Settings had been sharing the account silhouette with Users and the signed-in user label, so three different concepts read as the same icon. Settings now uses a gear, the user line the single-user icon, and Users a two-person group mark.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 16:06:38 +03:00
mix c443149623 docs: plan the narrow pages' heading alignment
test / test (push) Has been cancelled
Settings and the user form leave their heading at the left of the reading
measure while the card floats 12rem to the right of it. main > * caps every
child at 48rem and centres it; .card.narrow overrides the max-width down to
24rem but not the auto margins it never mentions, so the two centre on
different measures and half the difference is the offset.

Records what the question was actually about: not what is wrong with Settings
but what it shares with exactly one other page. Four templates use the narrow
card and eleven use a full-width one, which sorts every page into three groups
— full-width card, narrow card without navigation, narrow card with it — and
only the last has the problem. Settings and the user form are the only pages
that are both signed-in and built from a single narrow card; every other page
has one of those properties, never both.

Also records why the obvious fix is unavailable, so it is not tried a fourth
time: the shell centres the navigation and the page as a pair, so anything that
changes the column width is visible from the navigation (296px, measured). The
fix has to work inside the column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:19:11 +03:00
mix 5eaf665c01 docs: remove completed plans and trim the open-work tracker
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 01:34:04 +03:00
mix 7cc499d57c panel: show the send log's status as a badge
test / test (push) Has been cancelled
The send log was the one place in the panel where a status was bare text, next
to a delivery's own page where the same value is a badge, and a domain list
where the DNS verdict is one too — so a message's outcome looked like a
different kind of fact depending on which page you read it from.

The level travels on the row rather than being worked out in the template:
deliveryLevel is the one place that decides what a status means, the delivery
page already reads it, and a second mapping written into the template or the
stylesheet would have been free to drift from it. sent is ok, deferred a
warning, bounced and rejected errors, and queued unknown — nothing has gone
wrong, nothing has been reported yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:23:52 +03:00
mix aeba3f8db1 panel: put the narrow pages' column back, and centre the badge text
Two fixes in one file, both from looking at the result.

Settings and the user form were narrowed to their card's width so their heading
would stop floating beside it. The shell centres the navigation column and the
page as a pair, so narrowing the page moved the navigation with it — 296px
sideways between Settings and Domains, which is the jump the width rules at the
top of this file exist to prevent, arrived at from the other direction. Every
way of aligning the heading with the card moves something else, so the geometry
goes back to what it was before the restyle, rendered against the previous
stylesheet page by page to confirm it. Those two headings are still not aligned
with their cards; that is worth fixing on its own terms rather than by making
the chrome move on every page.

And the badge text sat above the middle of its box: the badge inherited the
body's 1.5 line-height, and a lowercase word with no descender — ok, warn,
unknown — sits high in a line box that tall, which inside a border reads as
text stuck to the top. The box now hugs its line and the padding does the
centring, uneven on purpose to pay for the descender space the words rarely
use. The one that does, queued, keeps the same box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:23:37 +03:00
mix d6e67ceb61 panel: set Sign out like the entries it stands among
The compact button rule is written for controls that cluster — several beside a
value or inside a table cell — where 0.8rem/600 is what stops four of them
shouting at once. Sign out borrows that rule for its colours, and was taking
the type with it: in the navigation column it has no cluster to belong to, so
it stood under Settings among entries set 0.95rem/400 as the one smaller,
heavier word on the column, reading as a different kind of object rather than
as the last item of the list.

Type and padding go back to the entries'. The red, the border and the tinted
background stay — those are what say it acts, and the restyle only made the
mismatch easier to see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:59:08 +03:00
mix 8ed20b0c98 docs: plan the logrotate mode defect
test / test (push) Has been cancelled
An image whose /etc/logrotate.d/mail is group-writable makes logrotate ignore
the file, so mail.log never rotates and grows until the volume does. The file
is 0644 in git and is widened on the way into the build context: COPY takes the
mode it finds, and an archive made from a checkout without POSIX permissions
carries the umask-widened one. Measured at 0644, 0664 and 0666 across three
images built on one host from the same commit range.

Written up rather than patched because three things are wrong and fixing the
visible one hides the rest: every COPY in the image trusts the build context,
logrotate exits 0 while ignoring the file so the rotate loop reports success,
and nothing anywhere notices that a rotation did not happen. Whether the
release workflow's own images are affected is stated as unverified — the
published image could not be pulled to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:56:49 +03:00
mix bd7a3b123d docs: close the CSP and font-ETag check against the test server
Deployed at 1.1.0-post.669f928: the policy is untouched and still admits the
fonts, each comes back as font/woff2 with a content ETag that answers a
matching If-None-Match with 304, and the signed-out page renders in Plex over
the network. The remaining open items all need a signed-in session with real
mail behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:49:45 +03:00
mix 669f928b4d docs: record what the visual-style pass left open
test / test (push) Has been cancelled
Five things, each written down so the next pass does not rediscover them: the
send log's bare-text status and the mapping question that keeps it from being a
pure repaint, the 375px horizontal overflow that predates this work, the three
views only ever seen empty because they need a running Postfix, the CSP and
ETag checks that a file:// render cannot make, and font-display: swap, which
never had a cold load to show itself in.

Also records how the pages were reached at all — the stubs and seeded rows
without which half the panel does not exist on a development machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:40:34 +03:00
mix f44f5333b3 panel: stop the send log breaking short words across lines
test / test (push) Has been cancelled
The last cell in a row is the one the automatic table layout squeezes first,
and in the send log it holds a fixed two-word link: "Details" was being split
after "Detail" on every row. Holding that cell on one line costs the row
nothing and hands the width back to the status column, which was breaking
"deferred" into "deferre" and "d" for the same reason — the status gets the
same nowrap the timestamp already had, through a class of its own since the
cell had nothing to select on.

Both predate the restyle; they surfaced while screenshotting the send log
against a seeded journal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:35:05 +03:00
mix 652f1fe438 panel: restyle on the mark's own palette and type
The stamp was approved on its own sheet and the panel kept the blue-on-cool-grey
it shipped with, so the mark read as pasted onto someone else's page. Brick
becomes the accent, the greys warm to paper, and the corners square up towards
the stamp's own edge.

Brick is two tokens rather than one. #7A3B2E under white text is 8.4:1, but as
text on the dark scheme's background it is about 2:1 — a link nobody can read.
So --accent-fill carries controls and --accent-text carries links and the active
navigation entry; in the light scheme they coincide, in the dark one they do not.

IBM Plex ships with the panel instead of being named and hoped for. The mark is
Plex converted to outlines, so a system stack left the panel's own name as the
only Plex on the page — and ui-monospace resolves to Consolas, SF Mono or DejaVu
Sans Mono depending on the machine, which laid the send log and mail.log tables
out differently for every operator. Three WOFF2 files, 76 KB, served from the
panel's own origin so default-src 'self' still covers them.

Two things changed shape rather than colour. Links had no rule at all and ran on
the browser's blue, invisible while the accent was blue and wrong the moment it
was not. And the column-narrowing that login and setup had by name now keys off
the narrow card itself, so Settings and the user form stop splitting their
heading from the card it belongs to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:24:17 +03:00
mix c9d726dd64 docs: agree the visual-style plan
test / test (push) Has been cancelled
The mark was approved on its own sheet and the panel was left on the default
blue-on-cool-grey it shipped with, so the two read as different designs. Write
down what the panel takes from the proof — brick as the accent, warm paper,
IBM Plex self-hosted — and what it deliberately does not: the proof's mock puts
navigation in a dark top bar, which has nowhere to keep the per-page section
index the left column carries.

Records the decisions that cost something to reverse: three font files (76 KB)
served from the panel's own origin so the CSP stays default-src 'self', brick
split into a fill and a text value because #7A3B2E is unreadable as a link on
the dark scheme, and the test that outranks all of it — the accent must not be
mistakable for a status badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:06:12 +03:00
mix f59befdb00 fix: include LICENSE in the Docker build context
The runtime stage copies LICENSE and NOTICE into /usr/share/doc/selfpost/
for AGPL-3.0 conveyance. Excluding LICENSE in .dockerignore broke clean
docker build once the cached layer was invalidated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:06:11 +03:00
mix 15baa1e5d0 feat(panel): add domain-admin role with per-domain authorization
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:43:59 +03:00
mix c9076655b9 docs: expand domain-admin plan with schema, routes, and UI spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:36:09 +03:00
mix e34d81498e release: 1.1.0
Close CHANGELOG [Unreleased] as 1.1.0; remove erroneous DMARC entries
retroactively copied into older sections; bump the compose pin and image
references.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-10 23:30:46 +03:00
mix 155b721438 Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled
Lay out view, auth, validate, and handlers under internal/web while keeping
the cmd/panel API unchanged; update roadmap and changelog for web-split closure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:19:09 +03:00
mix efaf016c5f Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:47:34 +03:00
mix 2bdc0ea9a8 docs: add visual-style candidate to roadmap
test / test (push) Has been cancelled
Track optional panel visual refresh in the 1.x+ tracker; changelog under Unreleased.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:02:24 +03:00
mix 6c8bf0d3b3 legal: close AGPL packaging gaps
test / test (push) Has been cancelled
Name the copyright holder, ship NOTICE with the image, serve the licence
from the panel footer on every page, and record the vendored htmx licence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 21:56:51 +03:00
mix c713f51f24 release: retag 1.1.0 as 1.0.1
test / test (push) Has been cancelled
The cut is docs and packaging plus one bugfix — PATCH, not MINOR. v1.1.0 was never tagged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 21:42:40 +03:00
mix 0ed36e9624 release: 1.1.0
test / test (push) Has been cancelled
Close the [Unreleased] section as 1.1.0 and move the deploy pin, the README
trial command, and the guide's stated pin to that tag.

Corrections to the section while cutting it:

- The GHCR "unknown blob" retry was filed under Unreleased, but the commit
  carrying it is what v1.0.0 points at. Moved into the 1.0.0 section.
- docs/plans/ and the roadmap's restructure into a 1.x+ tracker were missing
  entirely; the plans were described only as "translated", which reads as if
  they predated this release. Filed under Added.
- docs/product.md's reframing of the future line -- agreed 1.x+ extensions,
  inbound relay targeting a MINOR by default, approval needed only for
  candidates -- was unrecorded.
- The Account/Settings rename also touched the operator guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:39:53 +03:00
mix 5178f80fdf docs: changelog entry for the static-asset ETag
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:37:06 +03:00
mix 10c41677bd web: give the embedded static assets a content ETag
test / test (push) Has been cancelled
The assets are baked into the binary, so their FS modification times are the
zero value and http.FileServer sent no Last-Modified. With no ETag either, a
static response carried no validator at all and the browser was free to guess
how long to keep it -- which is how the panel kept showing the previous favicon
in the tab well after the new mark was deployed.

Hash each asset once at startup and serve that as a strong ETag, with
Cache-Control: no-cache so the browser keeps its copy but revalidates. An
unchanged asset then costs a bodyless 304 and a changed one is picked up on the
next load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:35:42 +03:00
mix 01c754f857 docs: record the decision to disclose AI authorship
test / test (push) Has been cancelled
The question kept resurfacing while preparing the repository for publication,
so it is written down as settled rather than re-argued: SelfPost is written by
AI agents under a maintainer's direction and the project says so.

What that commits to is listed explicitly — the Co-Authored-By trailers, the
model routing table, the .cursor rules file, and the agent-facing process notes
in the roadmap all stay, and none of them is an oversight to be tidied away.
The reasoning is that with the trailers already in ~140 commits, removing the
surrounding material would not conceal authorship, only signal an attempt at
it.

The revisit condition is deliberately narrow: a conflict with the licence or a
downstream obligation, not a shift in convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:14:27 +03:00
mix 691d3c255f docs: point the README at SECURITY.md and disambiguate the two security docs
test / test (push) Has been cancelled
A reader looking for where to report a flaw had no path from the README: the
only security row led to docs/security.md, which is the design rationale, not
a reporting channel. That row is now "Security design", and a line under the
table sends vulnerability reports to SECURITY.md instead of the issue tracker.

development.md gains SECURITY.md in its user-facing deliverables table and
drops "internal" from the roadmap row, which stopped being true when the
roadmap was reframed for outside readers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:58:56 +03:00
mix 295d1afb7d docs: translate the three plans
test / test (push) Has been cancelled
The roadmap links straight into these files, so a reader following an item
landed in Russian one click after an English page. Translated in full; goals,
boundaries, done-when criteria and risks are unchanged in substance.

The model assigned to inbound-relay is dropped, as it was from the roadmap
item: model routing lives in development.md.

Figures and code references were checked against the tree while translating
rather than copied forward — internal/web is still 50 files, 25 .go, ~4300
lines, and requireAuth(authed) and sendLogData are still where domain-admin.md
says they are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:50:38 +03:00
mix 06c2014384 docs: translate the roadmap and frame it for an outside reader
test / test (push) Has been cancelled
roadmap.md is linked from the README table, so it is a page strangers land on.
Translated in full, with a note up front that none of it is a commitment:
there are no dates, the order is a recommendation, and an item can be dropped
once its plan is written. The note also points at product.md for the opposite
question — what the project deliberately will not do — so absence from the
roadmap is not read as a silent plan.

The model assigned to inbound-relay is dropped from the item; model routing
belongs in development.md, not in a statement of direction.

The README row described the file as internal and Russian. It is now neither.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:46:58 +03:00
mix a4cfa11323 docs: retarget the CHANGELOG pointer to the renamed security.md section
test / test (push) Has been cancelled
The 0.5.0 entry pointed at docs/security.md § "Резервная копия и экспорт
домена", a heading that no longer exists after the file was translated. The
name is updated to the current heading; the entry otherwise stands as written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:45:19 +03:00
mix 236cb07769 docs: translate security.md and the last Russian source comments
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.

The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.

extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:39:53 +03:00
mix 04993e0da3 docs: add security policy
A public repository with no stated disclosure channel routes a finder into
opening a public issue, which discloses a relay flaw to everyone the moment it
is filed. SECURITY.md points at the repository private vulnerability reporting
instead, with public@mixeme.ru as fallback, and states scope so operator-side
configuration (blocked port 25, missing PTR, proxy TLS) does not arrive as a
report.

No response time is promised: a deadline that cannot be honoured by a single
maintainer is worse than none. Silence is explicitly not a request for a
continued embargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:32:09 +03:00
mix 7eb168f418 feat(panel): label the nav username with "User:"
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:49 +03:00
mix a1b6209470 feat(panel): rename Account to Settings
The nav entry, page heading, and browser title now read Settings. The
route, template name, and Active key stay `account`, so existing links
and bookmarks keep working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:17:11 +03:00
mixeme 44683a4996 docs: domain-admin may cover multiple assigned domains
Clarify that the global administrator sets which domains a domain-admin can manage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:21:43 +03:00
mixeme 870012514a docs: recommend web-split before domain-admin and inbound-relay
Document the preferred implementation order in the roadmap and linked plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:17:54 +03:00
mixeme 012802d83d docs: restructure roadmap as 1.x+ tracker with plan files
Split detailed design into docs/plans/ and keep roadmap as a status index;
align product, development, and README with the 1.x+ release line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:14:43 +03:00
154 changed files with 10919 additions and 4162 deletions
-1
View File
@@ -7,5 +7,4 @@ docs
bin
*.exe
README.md
LICENSE
Makefile
+63 -13
View File
@@ -1,11 +1,9 @@
name: release
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
# Ordinary commits do not publish anything — only a pushed tag matching
# vX.Y.Z does. That tag is the single source the version comes from: it goes
# into both the image tag and the panel binary's -ldflags version, so the two
# can never drift apart (the invariant restore's version check in spec 7.5.A
# depends on).
# Ordinary commits and bare git tag pushes do not publish anything. A published
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
#
# Native per-architecture builds (see docs/development.md), not qemu:
# 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
# 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
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering
# in the registry as a side effect (harmless — the version tag's immutability,
# spec 10.1, is about that tag, not these).
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
# so operators see a single version tag (spec 10.1).
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Image version as X.Y.Z (no v prefix)."
required: true
type: string
permissions:
contents: read
@@ -32,9 +34,25 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Derive version from tag
- name: Derive a SemVer X.Y.Z 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:
needs: prepare
@@ -49,6 +67,8 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3
@@ -113,6 +133,10 @@ jobs:
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.prepare.outputs.version }}
- uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
@@ -152,3 +176,29 @@ jobs:
attempt=$((attempt + 1))
backoff=$((backoff * 2))
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
+535 -3
View File
@@ -5,10 +5,540 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [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
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
`unknown blob` (and similar) errors after layers already uploaded.
- 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
- docs: operator and as-built docs aligned with the code after a full
pass — [architecture.md](docs/architecture.md) route table now marks
**global** routes (404 for domain administrators) and documents the
one-time restore Resync in Persistence; session/password and restore-session
wording corrected in [guide.md](docs/guide.md) and architecture (own-password
change vs admin reset, no "logout everywhere", immediate session restore on
the next request, PTR cache ≈1 min, decrypt has no version check, restore
Resync on first boot, domain add/delete/import and `POST /reload` global-only,
Settings DMARC global-only); [README.md](README.md) port-587 and quick-start
volume wording fixed; [security.md](docs/security.md) CSRF ADR points at
`authz.go` for route gating. No behaviour change.
- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
administration**, and **Domain administration** — DNS setup, operations,
rate limiting, and backup sections follow the instance/domain boundary
instead of mixing them. **Installation** now reads Ports → Local trial →
Initial setup → Full deployment (with the fixed image tag nested under it) →
Environment variables → Reverse proxy; the step-by-step production deploy and
per-proxy TLS commands move here from README's "Reference deploy" (README
keeps a short pointer). Internal (non-operator) environment variables move
to [architecture.md](docs/architecture.md) § Configuration; the guide keeps
a one-line pointer. **Full backup and restore** gains worked commands for
in-place restore, move-to-a-new-host, and encrypted-backup decrypt-first,
plus the version-mismatch error text. README anchors updated for the new
headings. No behaviour change.
- docs: the 2026-08-13 full-tree review plan is complete — every phase (P0P7)
is closed — and `docs/plans/code-review.md` is deleted per its own exit
criteria (history in git and in this file). The plan covered architecture,
quality, GUI, tests, and licence work; P0 was domain-admin send-log
authorization. The [roadmap](docs/roadmap.md)'s recommended order returns to
**queue-retries** and then **inbound-relay**; it still records
**schema-squash** (replace the 1.x SQLite migration chain with a 2.x baseline;
not a reason to cut a major on its own).
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
`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
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field
height fix. Upgrading is a tag bump; no migration.
### Changed
- rate limiting (level 2): domain ceilings apply to every client IP (no IP
allowlist). An application ceiling with trusted IPs is an override
**above** the domain limit (still ≤ level 1) and skips the domain check for
those IPs; without IPs the application override is inactive. When no domain
ceiling is set, non-privileged senders use level 1 only. The panel shows the
level-1 backstop on domain/application forms and Settings, rejects maxima
above level 1, and requires an application override to exceed the domain
maximum. Operator guide and architecture updated.
### Fixed
- panel: on the domain DNS status grid, the Type (TXT) field height matches
the Host fields.
## [1.2.3] - 2026-08-12
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
migration.
### Changed
- panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
connection settings ‖ add application; export ‖ danger). DNS status,
Applications and Domain settings are full-width. DNS status is two rows
(DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
and a Value label when records are present. Domain settings pairs DMARC
report mode with the optional level-2 domain rate limit; application Edit
opens address mode and an optional level-2 application rate limit side by
side (with a note that domain level-2 and global level-1 still apply); the
custom `rua=` address field is shown only for Custom address. The in-nav
“On this page” section index is removed. Shorter blurbs; *Sending server
settings* renamed **Connection settings**.
- panel: Domains list — **Add domain** sits beside the domain field; the
lead blurb under the form is dropped.
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
now `/settings` (legacy `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
nav labels and `<h1>` text; the backup page title is `SelfPost — backup &
migration` to reflect domain import as well as full backup. Operator guide and
architecture route tables updated.
### Fixed
- image: `mail.log` rotation no longer silently stops when the build context
ships `logrotate-mail.conf` with group/other write bits (common after a
Windows checkout sync). Runtime `COPY --chmod` pins config and script modes
in the Dockerfile; `logrotate-loop.sh` refuses a config logrotate would
ignore. E2e checks mode `644`, forced rotation, and a group-writable context
build.
## [1.2.2] - 2026-08-12
Status page layout after 1.2.1: paired cards in a wide column, denser machine
and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no
migration.
### Changed
- panel: **Status** is wide again so paired cards fill the column. Layout:
Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖
Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS
certificate, and Hostname (and Hostname's trailing detail line); milter
socket paths omitted from the table; queue link reads **View queue**; milter
ok detail is `Listening` without a trailing period and sits in its own
Detail column beside the status badge; CPU detail is only core and thread
counts (no load average); memory detail is `N used of M` without the
«available to new work» clause; network detail lists per-interface totals
only (rates stay in the Usage column). No «On this page» section index —
the paired layout is short enough. Hostname stays in the polled fragment so
the pair survives HTMX refresh. In-panel docs for the removed blurbs filed
as roadmap `panel-docs`.
### Fixed
- panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
shrinks each card to its content and centres it in the track instead of
filling half the row. Side margins are cancelled for `.split > .card`
(Status, Settings, and a delivery's message/history).
- panel: on the user create/edit form, **Assigned domains** checkboxes stacked
the box above the domain name (and stretched it full-width) because the form's
block-label and full-width input rules applied to them. Checkbox rows now use
the shared `label.check` layout; the fieldset has matching spacing.
## [1.2.1] - 2026-08-11
Panel refinements after 1.2.0: navigation icons, status-badge centreing,
drill-down back-link placement, and safeguards for the sole global
administrator. Upgrading is a tag bump; no migration.
### Fixed
- panel: status badge text sat low in the box (and below the heading or label
beside it). IBM Plex Mono sits low in its em square; the previous top-heavy
padding made that worse. Bottom padding is now heavier so the word centres
optically.
- panel: the **Users** navigation icon was two full silhouettes with staggered
baselines, so the pair looked lopsided at 16 px. The rear person is now a
right-side crescent (head + shoulder) behind a full front silhouette aligned
with `icon-account`.
- panel: the **Settings** navigation icon was a sun-with-rays (circle plus
spokes), not a gear. It now uses a toothed cog so it matches the other
session icons and the 1.2.0 release note.
- panel: the user create/edit form placed «Back to users» at the bottom of the
card instead of under the heading like the delivery, domain, and domain-delete
pages. A shared `back_link` template now renders every drill-down up-link, and
`TestDrillDownPagesPlaceBackLinkAboveContent` guards its position.
### Changed
- panel: the user edit form disables role change and delete for the only global
administrator, with a short note, instead of allowing the action and showing
an error on submit.
- panel: the user create/edit form hides **Assigned domains** when the role is
global administrator, since that role manages every domain anyway.
- panel: **Settings** shows panel credentials and DMARC aggregate reports side
by side for global administrators (the same `.split` layout as a delivery's
message and history). Domain-scoped users keep the single narrow card.
## [1.2.0] - 2026-08-11
The second MINOR after 1.0.0: domain administrators with per-domain scope, a
panel visual refresh on the SelfPost palette, and refinements to navigation and
the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the
single administrator becomes a global user.
### Added
- panel: **domain-admin role** — global administrators manage panel users and
assign domains; domain administrators see only their domains (applications,
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
backup, mail queue, system log, domain add/delete, and `/reload` stay
global-only. SQLite migration `0005_panel_users` migrates the single
administrator into a global user; sessions and full backup restore carry users
and domain bindings.
### Fixed
- Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
runtime image can copy it into `/usr/share/doc/selfpost/` as AGPL requires. A
clean build failed once the cached layer was invalidated.
- panel: on signed-in pages with a narrow card (**Settings**, the user form)
the heading, flash, card and footer now share one left edge. `.card.narrow`
had overridden only `max-width` while `main > *` still centred siblings on
the 48rem measure, so the card floated 12rem to the right of the title.
`main:has(> .card.narrow) > *` keeps the stack on 24rem without narrowing
the column, so the navigation stays put; login/setup are unchanged.
### Changed
- panel: navigation session icons are distinct — **Settings** uses a gear,
the signed-in user line carries the single-user icon, and **Users** a
two-person group mark instead of the same account silhouette for all three.
- panel: `/` redirects domain administrators to `/domains`; global users still
land on `/status`. Navigation hides global-only sections for domain
administrators.
- panel: **Sign out** in the navigation column uses the same type size and
weight as the page entries above it; only the red tint and border mark it as
destructive.
- panel: **visual style** brought in line with the SelfPost mark — brick accent
and warm paper in place of the blue-on-cool-grey defaults, IBM Plex Sans and
IBM Plex Mono served by the panel itself, squarer corners, and column
headings, status badges and small labels set in the mono face. Light and dark
schemes both keep their contrast; no page, control or workflow changed. The
send log stops breaking `Details` and `deferred` across two lines when a row
is tight. Badge padding and line-height are tuned so lowercase labels sit
centred in the box. The three WOFF2 files add ~76 KB to the image and are
served from the panel's own origin, so the Content-Security-Policy is
unchanged (`default-src 'self'`).
- panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
colours the status page and the DNS checks use, instead of the one place in
the panel where a status was bare text. The mapping is the one the delivery
page already applied — `sent` is ok, `deferred` a warning, `bounced` and
`rejected` errors, `queued` unknown because nothing has gone wrong yet.
- docs: [roadmap.md](docs/roadmap.md) and [product.md](docs/product.md) no
longer list domain-admin or visual-style as open work — both ship in this line.
Completed plan files (`domain-admin`, `visual-style`, `web-split`,
`narrow-page-alignment`) are removed; history stays in git and the entries
above. Inbound relay is the main agreed 1.x+ item left on the roadmap.
## [1.1.0] - 2026-08-10
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
packaging on every page, and an internal split of `internal/web` ahead of
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
existing DNS guidance is unchanged until you set a report address).
### Changed
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
is unchanged for `cmd/panel`. Templates and static assets moved under
`internal/web/view/`.
### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); the
panel footer on every page — including login and setup — shows copyright, a
link to `/license` (embedded AGPL text), a Source link to the public
repository, and "No warranty"; the runtime image ships `LICENSE` and
`NOTICE` under `/usr/share/doc/selfpost/`. `docs/development.md` now lists
the vendored htmx asset beside the Go module licences.
- [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel
visual refresh: typography, colour tokens, spacing, and component styling
without behaviour changes). Starting reference:
[docs/assets/selfpost-proof.html](docs/assets/selfpost-proof.html). No semver
impact; explicit agreement required before coding, like other candidates.
## [1.0.1] - 2026-08-09
A documentation and packaging release: no change to the mail path, the
database, or the on-disk layout. Upgrading is a tag bump.
### Added
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
fixes, and what is in and out of scope for a relay. No response time is
promised. Without it a finder's default move is a public issue, which
discloses a relay flaw to everyone the moment it is filed.
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
inbound relay, the domain-admin role, and splitting the oversized `web`
package. Each states scope, open questions, and what has to be true before
coding starts. [docs/roadmap.md](docs/roadmap.md) is restructured around them
as a 1.x+ tracker instead of a 2.x wishlist, and now says how to read it from
outside the project: nothing in it is a commitment, there are no dates, and
the stated order is a recommendation.
### Changed
- The panel's **Account** entry is now called **Settings** — nav link, page
heading, browser title, and the operator guide. The route stays `/account`,
so existing links and bookmarks are unaffected.
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
the current account rather than as a stray word above the Settings link.
- [docs/product.md](docs/product.md) reframes the future line: the inbound
relay and the domain-admin role are agreed **1.x+** extensions tracked in the
roadmap and the plans, with the inbound relay targeting a MINOR bump by
default and a 2.x major still possible pending implementation. Only items the
roadmap still marks *candidate* need explicit approval before coding. It
previously put the whole line behind a 2.x.x that nothing had committed to.
- [docs/security.md](docs/security.md) is now in English, matching the rest of
the published docs — it is linked from the README table and from
`SECURITY.md`, so a reader following either landed in Russian. Content is
unchanged: same requirements, same accepted risks, same ADR. The reviewing
model is no longer named in the text; the fact that a pre-release review ran,
and its date, stay. The roadmap and the plans are in English for the same
reason, and neither records the model assigned to an item any more.
- The README documentation table now points at `SECURITY.md` for reporting a
vulnerability, and the `docs/security.md` row is renamed *Security design*
with two files a reader could reasonably call "security", the table said
which is which only by accident. The roadmap row no longer calls the file
internal and Russian, because it is neither. `development.md` lists
`SECURITY.md` among the user-facing deliverables.
- `docs/development.md` records the decision on authorship: SelfPost is written
by AI agents under a maintainer's direction and the project discloses that,
so the `Co-Authored-By` trailers, the model routing table, and the agent
rules file all stay. Written down to settle the question rather than have it
reopened at each release.
- The two remaining Russian source comments are in English:
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
rather than translated — rendered in English it restated the sentence it was
attached to. The Cyrillic that remains is test data, where it is the point.
### Fixed
- The panel's static assets are served with a content ETag and
`Cache-Control: no-cache`. They are embedded in the binary, so their
modification times are the zero value and no `Last-Modified` was sent; with
no validator at all the browser was free to guess how long to keep them,
which is why a tab kept showing the previous favicon after the new mark
shipped. Each asset is now hashed once at startup, so an unchanged one costs
a bodyless 304 and a changed one is picked up on the next load. A browser
that cached an asset *before* this release still has nothing to revalidate
against, so that one copy has to be cleared by hand.
## [1.0.0] - 2026-08-09
@@ -59,6 +589,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
and mask the real error (release CI on both amd64 and arm64).
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
`unknown blob` (and similar) errors after layers already uploaded.
- A send-log row could stay `queued` forever after the container was recreated.
`mail.log` moved from the ephemeral `/var/log` into the data volume
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
@@ -368,7 +900,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
null recipient of a double bounce, `orig_to=` alongside `to=`, an
unrecognised status word, a capitalised one, and a cleanup line.
- docs: README *Encrypting a backup or export*; `docs/security.md` §
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
*Backup and domain export* + accepted risk (encryption is opt-in);
`docs/architecture.md` persistence § envelope summary.
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
release commit (move to `docs/archive/`, retarget its references in README,
+42
View File
@@ -0,0 +1,42 @@
SelfPost
Copyright (C) 2026 Mikhail Yenuchenko
This product is licensed under the GNU Affero General Public License,
version 3. The full licence text is in the LICENSE file in this
distribution (and at /license in the control panel).
Corresponding Source for the published releases is at:
https://github.com/mixeme/selfpost
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
your modified version. Update SourceURL in internal/legal/legal.go
so the panel footer points at your sources.
Third-party software included in this distribution
----------------------------------------------------
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
Copyright (c) Big Sky Software
Licence: Zero-Clause BSD (0BSD)
https://github.com/bigskysoftware/htmx
IBM Plex Sans / IBM Plex Mono
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
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
Distributed unmodified as three latin-subset WOFF2 files served by the
control panel:
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
internal/web/view/static/ibm-plex-mono-400.woff2
internal/web/view/static/ibm-plex-mono-600.woff2
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
the Google Fonts distribution. The wordmarks in the shipped SVG logos
are outlined paths derived from the same fonts.
Go module dependencies are listed with their licences in
docs/development.md § External libraries. Debian packages in the
runtime image carry their own copyright files as shipped by Debian.
+24 -136
View File
@@ -24,9 +24,9 @@ send log and DNS checks in the panel, encrypted backups.
- Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
- Full-server backup and single-domain export/import (optional password encryption)
- Single Docker image; data in a `./data` bind mount
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
## Documentation
@@ -35,11 +35,14 @@ send log and DNS checks in the panel, encrypted backups.
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
| [Architecture](docs/architecture.md) | As-built technical design |
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
| [CHANGELOG](CHANGELOG.md) | Release history |
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
the private reporting channel and the scope.
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
the `ghcr.io/mixeme/selfpost` image.
@@ -73,14 +76,15 @@ For every domain you add in the panel:
- [ ] DKIM TXT record (value shown on the domain page)
- [ ] 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
> **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
> 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
files, no compose files. Good for clicking through the UI on your machine;
@@ -92,7 +96,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.0.0
ghcr.io/mixeme/selfpost:1.3.0
```
**Get the setup URL** (pick one):
@@ -126,139 +130,23 @@ 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
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/) |
Full walkthrough — fetching the base files, setting up a reverse proxy and
TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up
DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
### 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.
### 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).
The compose file always publishes **465** and **587**; Postfix listens on 587
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables).
## License
Copyright © 2026 Mikhail Yenuchenko.
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
version as a network-accessible service, you must make the modified source
available to its users — not only when you distribute copies of the code.
Third-party notices: [NOTICE](NOTICE).
+81
View File
@@ -0,0 +1,81 @@
# Security policy
## Supported versions
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
1.x line** only; there is no backporting to earlier minors. Upgrade before
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
| Version | Supported |
|---|---|
| latest 1.x | yes |
| earlier 1.x | no — upgrade first |
| 0.x | no (pre-release) |
## Reporting a vulnerability
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
the *Report a vulnerability* button under the repository's
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
it, mail `public@mixeme.ru` instead.
Useful in a report: the image tag, the reverse proxy in front of the panel, the
steps to reproduce, and what an attacker gains. A relevant excerpt of
`mail.log` or the panel's system log helps; strip recipient addresses first.
**No response time is promised.** SelfPost is maintained by one person, and a
deadline that cannot be honoured is worse than none. Reports are read and
answered as soon as the maintainer is able; a fix ships in a patch release,
with the timeline agreed in the thread.
Disclosure is coordinated by request, not by demand: please hold public details
until a patch is out. If you get no reply, that is not a request for a
continued embargo — disclose at your own discretion. Reporters are credited in
the CHANGELOG unless they ask not to be.
## In scope
The relay's job is to accept authenticated mail from an application and hand it
to the internet as the operator's domain, and nothing else. Breaking that is in
scope:
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
a domain the sending application is not bound to
- **SASL bypass** — sending without valid credentials, or credential recovery
from anything the container exposes
- **Cross-domain access** — an application or a panel session reaching a domain
it was not granted
- **Secret disclosure** — DKIM private keys, the admin password hash, session
tokens, or backup encryption material leaking to an unauthorised party
- **Panel authentication and session flaws** — login bypass, session fixation,
CSRF on state-changing routes, privilege escalation
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
per-domain and per-application limits
- **Container escape** or privilege escalation from the panel's unprivileged
user to root
## Out of scope
These are the operator's responsibility or accepted trade-offs, documented in
[docs/security.md](docs/security.md) and the
[operator guide](docs/guide.md):
- Host configuration the operator controls: a blocked port 25, a missing or
wrong PTR record, DNS records not published, a self-signed or expired
certificate on the reverse proxy
- Anything requiring the attacker to already have root on the host or write
access to the `./data` bind mount
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
never terminates HTTPS itself
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
a policy decision of that provider, not a defect
- Denial of service through sheer volume against a single-tenant relay
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
those upstream; if SelfPost's configuration makes an upstream issue
exploitable when it otherwise would not be, that *is* in scope
## Reports we cannot act on
Automated scanner output with no demonstrated impact, and reports whose only
content is a version number compared against a CVE list, are closed without
investigation.
+20 -12
View File
@@ -86,18 +86,26 @@ RUN mkdir -p /run/opendkim /run/selfpost /data \
COPY --from=build /out/panel /usr/local/bin/panel
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
COPY build/opendkim.conf /etc/opendkim.conf
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
COPY build/crashexit.py /usr/local/bin/crashexit.py
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
# 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 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
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
# 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/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-config.sh /usr/local/bin/postfix-config.sh
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
+40 -1
View File
@@ -14,10 +14,49 @@
# often than daily — polling merely bounds how late a legitimate rotation runs.
set -eu
CONFIG=/etc/logrotate.d/mail
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
# logrotate refuses configs writable by group or others and exits 0 while
# ignoring them — fail here so supervisord reports the fault.
logrotate_config_ok() {
mode=$(stat -c '%a' "$CONFIG")
mode=${mode#0}
grp=$(( (mode / 10) % 10 ))
oth=$(( mode % 10 ))
case $grp in 2|3|6|7) return 1 ;; esac
case $oth in 2|3|6|7) return 1 ;; esac
return 0
}
logrotate_config_fatal() {
echo "logrotate-loop: refusing to run: $CONFIG mode $(stat -c '%a' "$CONFIG") is writable by group or others" >&2
exit 1
}
if ! logrotate_config_ok; then
logrotate_config_fatal
fi
run_logrotate() {
out=$(logrotate "$CONFIG" 2>&1) || {
echo "$out" >&2
return 1
}
case "$out" in
*Ignoring*|*Potentially\ dangerous\ mode*)
echo "$out" >&2
logrotate_config_fatal
;;
esac
return 0
}
while true; do
if logrotate /etc/logrotate.d/mail; then
if ! logrotate_config_ok; then
logrotate_config_fatal
fi
if run_logrotate; then
:
else
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
+3 -2
View File
@@ -104,8 +104,9 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
; in the image). Runs as root so logrotate can read/rotate the log; never exits
; non-zero, so it neither trips the crashexit listener nor needs restarting.
; in the image). Runs as root so logrotate can read/rotate the log. Exits
; non-zero when the config is group/other-writable (logrotate would ignore it
; silently); autorestart surfaces BACKOFF on the Status page.
[program:logrotate]
command=/usr/local/bin/logrotate-loop.sh
priority=400
+1 -1
View File
@@ -17,7 +17,7 @@ var documentedPublic = []string{
"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{
"SELFPOST_DATA_DIR",
"SELFPOST_DB_PATH",
+60 -10
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
@@ -15,17 +16,57 @@ import (
"github.com/mixeme/selfpost/internal/web"
)
// 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 {
// Applications own the SASL accounts and the Postfix sender map; the domain
// service delegates to them when a domain (and its applications) is deleted.
pf := postfix.New(cfg.postfixDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
// mailStack is the panel's domain and application services plus the on-disk
// mail-path adapters they write through.
type mailStack struct {
Domains *domain.Service
Apps *app.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
}
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,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
@@ -39,7 +80,16 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
}, 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 {
return err
}
+17 -1
View File
@@ -2,6 +2,8 @@
// several roles (architecture.md § Image and processes) as a supervised
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
// the rate-limit checks.
//
// Copyright (C) 2026 Mikhail Yenuchenko
package main
import (
@@ -60,6 +62,8 @@ type config struct {
trustedProxies []*net.IPNet
sessionIdleDays int
dnsResolvers []string
rateLimitMessagesPerIP int
rateLimitWindowSeconds int
// Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror
@@ -114,6 +118,10 @@ func loadConfig() config {
// means dnscheck's public defaults; a closed network names its own here.
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
@@ -214,7 +222,8 @@ func run() error {
// touch the database, so schema/format skew between versions cannot corrupt
// the restored state. A match consumes the manifest; its absence is the
// 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
}
@@ -227,6 +236,13 @@ func run() error {
}
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
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
}
+2
View File
@@ -16,6 +16,8 @@
// the same password:
//
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
//
// Copyright (C) 2026 Mikhail Yenuchenko
package main
import (
+1 -1
View File
@@ -22,7 +22,7 @@
services:
selfpost:
image: ghcr.io/mixeme/selfpost:1.0.0
image: ghcr.io/mixeme/selfpost:1.3.0
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+4 -4
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
# PEM"). Run this on the host, after Traefik has issued or renewed the
# certificate, and again on a schedule (cron/systemd timer) since acme.json
# is not itself watched by SelfPost/Postfix.
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# step is required"). Run this on the host, after Traefik has issued or
# renewed the certificate, and again on a schedule (cron/systemd timer) since
# acme.json is not itself watched by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu
+78 -27
View File
@@ -81,11 +81,13 @@ One process, three roles:
proxy only.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. The level-2
count is the stored send-log rows plus the messages this process has admitted
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
cannot each spend the same last slot; a reservation is released at
end-of-message, on ABORT, or after a 10-minute TTL.
(`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling with trusted IPs
raises the limit for those IPs only and skips the domain check (guide § Rate
limiting). The level-2 count is the stored send-log rows plus the messages
this process has admitted but not yet written (`internal/milter/inflight.go`),
so concurrent sessions cannot each spend the same last slot; a reservation
is released at end-of-message, on ABORT, or after a 10-minute TTL.
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
@@ -152,25 +154,31 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. The table below is a summary — HTMX fragment endpoints
unless noted. Routes marked **global** return **404** for domain administrators
(`requireGlobal()` in
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
| Route | Purpose |
|---|---|
| `/healthz` | Liveness (no auth) |
| `/license` | Embedded `LICENSE` text (no auth) |
| `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
| `/domains/import` | Domain import (`POST`; form on the Backup page) |
| `/deliveries` | Send log with filters |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines |
| `/mail-queue` | Postfix queue view |
| `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) |
| `/account` | Admin username/password |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
@@ -188,14 +196,17 @@ holds the cookie works after process restart, redeploy, or full backup restore.
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
- **Password change** — all other sessions are deleted; the current session stays
active ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_account.go](../internal/web/handlers_account.go)).
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change on `/settings`** — changing your own password deletes
every other session for that user; the current session stays active
([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
Restoring an **older** backup also restores session rows: a session invalidated
after that backup was taken can become valid again if the browser still has the
cookie and idle timeout has not expired.
Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still holds
the cookie and the restored row's `expires_at` has not passed.
---
@@ -218,7 +229,15 @@ flowchart TB
backupcli["selfpost-backup CLI"]
end
subgraph web ["internal/web — HTTP surface"]
handlers["handlers_*.go, templates, session/security"]
webRoot["web.go — router, security"]
viewPkg["web/view — templates, static"]
authPkg["web/auth — session, login, setup"]
handlersPkg["web/handlers — authenticated pages"]
webRoot --> viewPkg
webRoot --> authPkg
webRoot --> handlersPkg
handlersPkg --> authPkg
handlersPkg --> viewPkg
end
subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"]
@@ -282,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above).
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
restore. Stopped-container `tar` of `./data` is safe (see guide).
restore. On the first successful boot after restore, the panel runs one
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, so drift between the extracted archive
and the database is healed before mail flows (same step as `POST /reload` on
demand). Stopped-container `tar` of `./data` is safe (see guide).
**Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
@@ -306,5 +329,33 @@ origin check, no CSRF tokens) are documented there separately.
## 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).
**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`).
+124 -22
View File
@@ -1,8 +1,9 @@
# SelfPost — development
**What this file is.** How to build, test, document, and ship changes. Open
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
[architecture.md](architecture.md).
---
@@ -11,16 +12,46 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
After `/clear` or a fresh chat:
1. Read this file (process, docs rules, model routing).
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
[security.md](security.md); as-built — [architecture.md](architecture.md).
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
plan file for the active item. Accepted risks — [security.md](security.md);
as-built — [architecture.md](architecture.md).
3. Skim [product.md](product.md) if scope is in doubt.
4. Continue from the next unchecked step in the active plan.
4. Continue from the next unchecked step in the **active** plan file (not the
roadmap index).
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
not duplicated here.
---
## Authorship and disclosure
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
a maintainer's direction, and the project says so rather than hiding it.
Concretely, this is what "says so" means, and none of it is an oversight to be
tidied away later:
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
the ~140 commits that predate v1.0.
- The model routing table below is public, in a file the README links to.
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
the repository.
- Process notes written for an agent — "after a context reset, pick an item
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
**Why not quietly drop it.** Once the trailers are in the history, removing the
routing table or the rules file would not conceal authorship, it would only
make the project look like it was trying to. Partial concealment reads worse
than the plain statement, and the plain statement costs nothing: the code is
reviewed, tested, and shipped under the same rules either way, and the
[security design](security.md) records what was audited and what was accepted.
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
obligation — not because the convention around AI authorship shifts.
---
## Model routing
| Kind of work | Model | Examples |
@@ -61,8 +92,10 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
## External libraries
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must
be permissive or GPL-family (see
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
`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)).
### Main module (`go.mod`)
@@ -76,6 +109,13 @@ be permissive or GPL-family (see
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
the tree are AGPL-3.0-compatible.
### Vendored front-end
| Asset | Version | Repository | License |
|---|---|---|---|
| `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`)
| Package | Version | Repository | License |
@@ -90,6 +130,10 @@ the image.
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian bookworm repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/bookworm/>.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
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`.
---
@@ -101,7 +145,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
```sh
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.0.0
make build VERSION=1.2.3
```
Or directly:
@@ -122,8 +166,10 @@ docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
```
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
runtime stage (Debian + mail stack). See [architecture.md](architecture.md) §
Image and processes.
runtime stage (Debian + mail stack). Runtime config and scripts use `COPY
--chmod` so file modes in the image do not depend on how the build context was
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
See [architecture.md](architecture.md) § Image and processes.
---
@@ -145,9 +191,12 @@ tag / push only on explicit request (see `release.yml`).
### Release image
The release image is published **only on tag** `vX.Y.Z` (not on every push to
`main`). The tag is the single source of version: it drives the image tag and
`-ldflags` in the binaries so they cannot drift apart.
The release image is published **only** for a SemVer version `X.Y.Z`: a
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
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):**
@@ -155,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
local-trial image references) in the **same** release commit.
2. Create and push git tag `vX.Y.Z` on that commit.
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
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
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
@@ -223,7 +306,11 @@ operator would actually use.
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
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.
@@ -238,21 +325,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
`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]
→ docker build --load (VERSION from tag)
→ docker build --load (VERSION from prepare)
→ e2e (test/e2e)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge
→ 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
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.
@@ -273,7 +372,8 @@ There is no `docs/archive/` directory.
| As-built design | [architecture.md](architecture.md) |
| Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.md) |
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
| Active design plans | [plans/](plans/) |
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
### User-facing deliverables
@@ -282,7 +382,9 @@ There is no `docs/archive/` directory.
|---|---|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
@@ -311,7 +413,7 @@ to prose.
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
+531 -231
View File
@@ -3,55 +3,53 @@
Detailed install, configuration, and day-to-day operations. For a short
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
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Local trial](#local-trial)
- [Environment variables](#environment-variables)
- [DNS setup](#dns-setup)
- [IP warmup](#ip-warmup)
- [Operations](#operations)
- [Rate limiting](#rate-limiting)
- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain)
- [Installation](#installation)
- [Ports](#ports)
- [Local trial](#local-trial)
- [Initial setup](#initial-setup)
- [Full deployment](#full-deployment)
- [Fixed image tag](#fixed-image-tag)
- [Environment variables](#environment-variables)
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Instance administration](#instance-administration)
- [Status](#status)
- [Mail queue and System log](#mail-queue-and-system-log)
- [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)
- [Published ports](#published-ports)
- [Fixed image tag](#fixed-image-tag)
- [Domain administration](#domain-administration)
- [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
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.
### Ports
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 |
|---|---|---|
| **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
### Local trial
The [README quick start](../README.md#quick-start) runs a single container
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).
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
`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` |
| `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` |
| `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` |
@@ -106,112 +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`
bind mount — configure the mount, not these variables.
**Internal variables (not part of the operator interface).** The following are
read by the panel or startup scripts but are not meant to be changed in a
normal deployment; documenting them here avoids treating accidental overrides as
supported configuration:
The image also reads a number of internal, non-operator env vars (paths,
timeouts, tuning) — not part of this interface; see
[architecture.md § Configuration](architecture.md#configuration) if you need
them.
- **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`).
### Reverse proxy (mandatory)
## 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):**
- **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.
| Proxy | Where certs live | Fragment |
|---|---|---|
| **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/) |
**Domain level (for *every* sending domain you add in the panel):**
- **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts
building reporting/reputation history).
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."
Skipping any of the three per-domain 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.
**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.
The panel checks both scopes for you and tells you what is actually published:
the *Status* page verifies the server's hostname and its reverse record
(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 and DMARC 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.
In every case the proxy terminates HTTPS for the panel; the resulting
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
## 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
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.
```sh
sudo certbot --apache -d mail.example.com
```
## 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
service healthy and will mail be accepted?"
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
TLS certificate validity and expiry, milter socket presence, and a short
Postfix queue summary. The **Machine** card adds the resource usage of the
host underneath — processor (with the load average), memory and swap, and
per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning here, because both delay or kill the mail path;
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The
**Reload configuration** button re-applies OpenDKIM tables and the Postfix
sender map from the database — use it if daemons drifted from what the panel
shows after manual edits under `/data`.
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
(level 2) are configured here. *Export domain* writes a single-domain archive;
*Import a domain* on the Backup page reads one back in.
- **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`.
### Status
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
certificate validity and expiry, milter socket presence, and a short Postfix
queue summary. The **Machine** card adds the resource usage of the host
underneath — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning here, because both delay or kill the mail path;
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database — use it if daemons drifted from what
the panel shows after manual edits under `/data`.
### Mail queue and System log
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
@@ -220,112 +326,206 @@ service healthy and will mail be accepted?"
hours. It lives in the data volume, so it survives a container recreate along
with the rest of the state — `./data/log/` on the host — but it is *not*
included in backups: it is diagnostics, not state.
- **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).
- **Account** (`/account`) — 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
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
### Settings
`/settings` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). 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
stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does
**not** count as activity, so an auto-refreshing tab left open will not keep a
session alive forever. Changing the password signs out every other session but
leaves the current browser signed in.
session alive forever. Changing **your own** password on `/settings` signs out
every other session for that user but leaves the current browser signed in.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
**Upgrading.** 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
### Upgrading
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).
**Container health.** The image declares a Docker `HEALTHCHECK` that probes
`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.
### Container health
**First-time setup link.** 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. If this host ships container logs to a central
aggregator, prefer reading the file:
The image declares a Docker `HEALTHCHECK` that probes `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](#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.
```sh
docker compose exec selfpost cat /data/setup-token
```
### Server-level DNS (PTR/rDNS)
## 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 limits; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log.
The [Status](#status) page verifies the server's hostname against this
record (forward-confirmed reverse DNS). Results are cached for about one
minute; use *Re-check* right after publishing a record.
**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_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
This is an anvil limit per connecting client IP. It keeps working even if the
journal-milter (level 2) is down.
journal-milter (level 2) is down. There is no per-IP bypass.
**Level 2 (per domain / per application)** — optional, configured in the panel
on each domain's page or on an individual application. You set a message
ceiling, a time window, and optionally restrict the limit to specific client
IPs; an empty IP list means the differentiated limit does not apply. When
exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as
`rejected`.
### Full backup and restore
## Backup, restore, and moving a single domain
**Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
all applications' SASL credentials, `manifest.json` with the version that
created it): panel button (*Backup* → *Full backup*), or from the host:
Two related but distinct operations
([architecture.md](architecture.md) § Persistence):
```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
```
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
all applications' SASL credentials, `manifest.json` with the version that
created it): panel button (*Backup* → *Full backup*), or from the
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.
**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)).
**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.
**Restoring in place** (same host — recovering from data loss, or rolling
back after a bad change):
- **Export/import a single domain** (domain page → *Export domain* to write the
file, *Backup**Import a domain* to read it back in): 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, this works across different hostnames/instances.
```sh
# 1. Stop the instance being replaced
docker compose down
Both files 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.
# 2. Move the current /data aside rather than deleting it, start from empty
mv ./data ./data.before-restore
mkdir ./data
### Encrypting a backup or export
# 3. Unpack the backup into the fresh directory
tar xzf selfpost-backup.tar.gz -C ./data
# 4. docker-compose.yml must already pin the exact tag the backup was made
# with — check the archive's manifest if unsure:
tar xzf selfpost-backup.tar.gz -O manifest.json
# 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
file that comes down is an encrypted envelope instead of the plain archive:
@@ -365,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.
## Published ports
## Domain administration
`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.
### Domains page
## 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`),
deliberately never `:latest`. The current pin is `1.0.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 above:
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.
### Domain-level DNS (SPF, DKIM, DMARC)
For *every* sending domain you add in the panel:
- **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **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).
+50
View File
@@ -0,0 +1,50 @@
# Plan: dmarc-reports
**Status:** candidate
---
## Goal
SelfPost **receives** DMARC aggregate reports on SMTP, parses them inside the
image, and **shows summaries in the panel** — pass/fail by source, hints when
`tighten p=` is reasonable. No external DMARC SaaS and no IMAP workflow for the
operator.
## Scope
**In:**
- Inbound SMTP for configured report addresses only (not a general backup-MX).
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
- Panel page and/or per-domain section: recent reports, third-party senders,
delivery health of report ingestion.
- 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.
**Out:**
- Forensic reports (`ruf=`).
- Full dashboards, APIs, email alerting.
- Mailboxes for people (IMAP/POP3/webmail).
## Architecture (sketch)
1. Receiving MTAs → SMTP to SelfPost (hub MX).
2. Postfix virtual alias or dedicated listener → panel ingest worker.
3. Parse XML → `dmarc_reports` table (domain, reporter, counts, date).
4. Panel reads SQLite; links from domain DNS card.
May share port-25 plumbing with [inbound-relay.md](inbound-relay.md) but must
remain a separate, opt-in feature that does not forward mail upstream.
## Done when
- Operator can point `rua=` at an address SelfPost accepts and see parsed
summaries in the panel within one reporting cycle.
- With the feature off, outbound-only behaviour is unchanged.
- Documented in [guide.md](../guide.md); migrations are backward-compatible.
## Risks
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
- Report volume and retention (mitigate: caps + pruning).
+171
View File
@@ -0,0 +1,171 @@
# Plan: inbound-relay (inbound relay)
**Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance).
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
The domain-admin role
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
`[1.2.0]`.
---
## Goal
The ability to accept mail on port 25 for explicitly configured domains and
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
a **module disabled by default** that changes neither the behaviour nor the
attack surface of the base outbound relay.
## What it is for (scenarios)
- **Backup-MX** — accept mail while the domain's primary mail server is
temporarily unreachable, and hand it over when it comes back.
- **A front for a server without a public IP** — the operator runs their own
mail server which, for whatever reason, **cannot accept mail from the
internet itself** (no static or public IP, behind NAT, a private address,
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
PTR, acts as the domain's public entry node (the MX points at it) and
forwards mail to that internal or otherwise unreachable server.
## Scope boundary (critical — what this is NOT)
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
forwarding (relay/forward) to an upstream (`relay_domains` +
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
with no local delivery.
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
**neither implements nor bundles** an anti-spam or anti-virus engine
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
filtering onto the backend either (see the "Anti-spam" section below): it
provides an attachment point for an external filter.
## Why as an option / plugin
- Accepting on port 25 changes the threat model (open relay for inbound,
backscatter, spam ingress). So it is **off** by default behind the
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
the operator.
- Isolation: separate SQLite tables, separate panel handlers and pages, a
separate branch of config generation. With the flag off, the inbound
listener, the tables and the UI are absent — the base outbound path is
byte-for-byte unchanged.
## What to do
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
the inbound service and its config from panel state the same way the rest of
the config is generated (`postfix-config.sh`).
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
SASL is **not** offered and sending outwards is **not** allowed — inbound
only, for `relay_domains`.
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
**only** for domains in `relay_domains` and **only** for known recipients
(`relay_recipient_maps`); everything else gets
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
accepting "for anyone", is impossible.
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
recipient at RCPT stage) so that bounces to non-existent addresses are never
generated.
- **The panel manages:** the list of inbound domains; for each one the upstream
destination (`host:port`, transport), an optional list of valid recipients,
and optional TLS to the upstream. Strict validation of domain, host and port
(whitelist), injection-safe writing of map files (as with
`sender_login_maps` in Phase 4), `os/exec` without a shell
([security.md](../security.md)).
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
someone else's inbound mail). The journal-milter can optionally be reused for
an inbound journal (extra work), or the inbound path can go without it in the
first stage; fail-open behaviour is preserved.
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
`message_size_limit` on the inbound smtpd.
## Anti-spam (important, but optional)
This is a valuable option, but it is **not mandatory**: some operators will be
perfectly served by **blind forwarding without filtering** — when the backend
can filter on content itself, when the upstream is trusted, or when the volume
and risk are low. So the anti-spam hook is **off** by default (an empty
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
it.
What matters is something else: where filtering is technically possible. With a
"blind" relay the destination backend sees **SelfPost's** address as the
connecting IP, not the original sender's, so everything on the backend that
depends on the origin IP breaks (DNSBL and reputation are checked against
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
SPF). **The only point where the real client IP is still visible is the inbound
hop at SelfPost** — so for those who need filtering, it has to be *attachable
right here*, not delegated to a backend that has already lost the information.
The attachment design:
- **The anti-spam engine is a separate optional container** (rspamd or
similar), which the operator runs **only if this option is wanted** (the same
principle as the reverse proxy — a separate container outside the SelfPost
image). SelfPost **neither contains nor starts it** — the image and the "one
container, three processes" principle are unchanged, and
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
does not implement anti-spam).
- **SelfPost provides the attachment point:** a milter hook on the inbound
smtpd. The engine's address is set via env (for example,
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
the true origin. `milter_default_action` for that milter is configurable
(fail-open vs tempfail); the default is to be decided during implementation.
- **A native backstop with no dependencies:** on that same inbound hop,
Postfix's own origin-IP facilities are available — `reject_rbl_client`
(DNSBL) and HELO/PTR checks — and they work even without an external
container. Plus preserving authentication results for downstream through ARC
or `Received`, where part of the filtering does remain on the backend.
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
alternative reverse-proxy fragments) — the container comes up with the stack
only when the option is enabled.
- **Persistence:** new tables and map files under `/data` — they land in the
full backup automatically (Phase 9). Domain export/import can be extended
with the inbound configuration — optional, to be flagged.
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
server (unlike outbound, where no MX is required) — to be reflected in the
README's DNS section.
## Security
[security.md](../security.md): server-side input validation, escaped writes to
config files, `exec` without interpolation, no open relay, protection against
backscatter.
## Done when
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
25 for that domain is forwarded to the given upstream; mail for unconfigured
domains or recipients is rejected (not an open relay, no backscatter); with
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
with the real origin IP (verified with a sidecar container), and with it empty
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
port, tables and UI are absent and the base outbound relay is unchanged;
`build`/`vet`/`test`/image green.
## Risks
- open relay / backscatter — removed by `relay_domains` +
`relay_recipient_maps` + `reject_unauth_destination`;
- the loss of the origin IP for filtering on the backend when forwarding —
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
where the origin IP is still visible;
- port 25 accepting mail widens the attack surface (off by default);
- semver: if the contract turns out incompatible (ports, backup, behaviour with
the flag off) a major `2.x` is possible; the decision comes after the
implementation.
**External deployment dependency:** the optional anti-spam container — outside
the SelfPost image, brought up by the operator when the option is enabled.
## Dependencies
A finished outbound path (already implemented). Agreement obtained — see the
status above.
+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.
+12 -6
View File
@@ -47,14 +47,19 @@ Explicitly excluded to prevent scope creep:
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
- Webmail
- Multi-user panel / organisations / roles — one administrator; managing
**multiple sending domains** is in scope (see below)
- Organisations / tenancy beyond global + domain-admin roles; managing
**multiple sending domains** under one global administrator is in scope (see
below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay** is
the main agreed **1.x+** extension still on the
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
remains possible pending implementation. Items marked *candidate* in the
roadmap require explicit approval before coding.
---
@@ -99,5 +104,6 @@ Adding a domain does **not** create an application automatically.
- **Delete domain** — removes DKIM key and **all** its applications.
- **Delete application** — removes only that app's SASL and map entries.
This is not multi-tenancy (one admin); it is one owner operating several
sending domains with independent application credentials.
This is not multi-tenancy; it is one owner (or a small team with global and
domain-scoped roles) operating several sending domains with independent
application credentials.
+183 -121
View File
@@ -1,133 +1,195 @@
# Дорожная карта: SelfPost 2.x.x
# Roadmap: open work (1.x+)
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
только после явного согласования ([product.md](product.md),
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
[product.md](product.md) явно исключает часть этого объёма (приём входящей
почты; несколько пользователей/роли), поэтому включение — сознательное
расширение границ проекта, а не доработка по своей инициативе. Присутствие
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
решением.
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
only after explicit agreement ([product.md](product.md),
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
design lives in [plans/](plans/). Items marked `candidate` need an OK before
any code is written.
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
[CHANGELOG.md](../CHANGELOG.md).
**Reading this from outside the project:** nothing here is a commitment or a
release promise. There are no dates, the order is a recommendation rather than
a schedule, and an item can be dropped or reshaped once its plan is written.
What the project *will not* do is a separate question, answered in
[product.md](product.md) — an item's absence from this file does not mean it is
planned but unlisted.
**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
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
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
---
## v1.x — хвост документации и деплоя
## Index
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
| 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) |
| contributing | `CONTRIBUTING.md` | candidate | — |
| 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 | — |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — |
| Тема | Итог |
**Recommended order** (not binding): **queue-retries** is a small panel item
that can land first or in parallel; the next feature is **inbound-relay**.
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
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
**Goal:** optional acceptance of mail on port 25 for explicitly configured
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
unchanged.
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
engine stays outside the image, only the attachment point is provided.
**Done when:** see the criteria in
[plans/inbound-relay.md](plans/inbound-relay.md).
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
a wider attack surface (port 25 accepting mail).
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
implementation lands.
---
## contributing
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
before a PR, the commit protocol; [development.md](development.md) links to it
rather than repeating it.
**Boundary:** process documentation; worth writing once there is an external
flow of PRs.
**Done when:** the file is in the root and development.md does not duplicate
it.
**Dependencies / risks:** with a single developer and no PRs, this is low
priority.
**Version:** no bearing on semver.
---
## dmarc-reports
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
the operator does not need an external DMARC service or a separate mailbox
workflow.
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
inbound relay. A dedicated inbound path for report messages only; forensic
reports (`ruf=`) out of scope for v1.
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
storage and retention of parsed summaries; the `dmarc_report_email` setting
(migration `0005` moved it off the old `admin` table into `settings`) and
`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
inbound-relay depending on how port 25 acceptance is structured.
**Version:** `1.x` MINOR.
---
## panel-docs
**Goal:** built-in operator documentation in the panel — short pages (or a
help drawer) that explain what each Status check and other controls mean,
without sending the operator out to `docs/guide.md`.
**Boundary:** in-panel help only; not a second copy of the full operator guide.
Seed content includes the Status blurbs removed from the cards in favour of a
denser layout — Machine (kernel counters / rate window), TLS certificate
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
reverse DNS, PTR at the hosting provider), and similar notes for other panel
surfaces as they lose inline commentary.
**Done when:** an operator can open help from the panel for those topics; the
removed Status blurbs are preserved there (or equivalent); no requirement to
read the git tree for day-to-day meaning of a card.
**Dependencies / risks:** copy ownership and translation; keeping help in sync
when checks change; not bloating every page with a second column of prose.
**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 |
|---|---|
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
| `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 |
Открытая работа дальше — только секции 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.
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
**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.
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
**Зачем это нужно (сценарии):**
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
**Граница объёма (критично — что это НЕ):**
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
**Почему как опция/плагин:**
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
**Что делать:**
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
---
## Роль администратора домена — кандидат на 2.x
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
панели, роли» к out of scope (один администратор), поэтому появление второго
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
---
## `CONTRIBUTING.md` — кандидат на 2.x
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
моделей по типу работы, протокол коммитов, требование
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
[development.md](development.md) (английский процесс) и в этом файле (открытая
работа, русский).
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
проект вовне: английская документация процесса ([development.md](development.md),
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
русском) и первый внешний интерес после публикации релиза.
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
проверкам перед PR и протокол коммитов; [development.md](development.md) не
дублирует его, а ссылается.
---
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
2.x выше добавляют в него код — роль администратора домена приносит
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
разрезан, либо зафиксировано, что он остаётся плоским.
+192 -141
View File
@@ -1,177 +1,228 @@
# Безопасность
# Security
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
defence-in-depth — `--` перед логином в argv `saslpasswd2`
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски**
сознательные отступления сверх обязательного, чтобы решение не потерялось.
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
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
in full: no exploitable findings; one defence-in-depth change — `--` before the
login in the `saslpasswd2` argv
([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
lost.
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
cookie with duplicate detection — Phase 14) is done; the history is in
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
Продуктовые границы: [product.md](product.md). Устройство as-built:
Product boundaries: [product.md](product.md). As-built design:
[architecture.md](architecture.md).
---
## Обязательные требования
## Mandatory requirements
Панель публична из интернета — пункты ниже **не опциональны**.
The panel is exposed to the internet — the items below are **not optional**.
### Первичная инициализация администратора
### First-run administrator setup
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
настройки — перегенерация и новый вывод в лог.
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
ready-made password hash.
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
unfinished, it is regenerated and logged again.
- Rate limiting on `/setup/<token>` per IP, separate from login.
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
- Once the administrator exists the token is void forever, `/setup/*` → 404.
- The administrator password is bcrypt in SQLite only; no plaintext and no
MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
### SASL-пароли приложений
### Application SASL passwords
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
перевыпуск.
- The panel **generates** the password on creation or reissue and shows it
**once**.
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
panel); a lost password can only be reissued.
### Ввод и конфигурация
### Input and configuration
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
защитой.
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
- `postfix reload` и любой `exec`**без** shell-интерполяции пользовательского
ввода; аргументы отдельными элементами.
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
- Server-side validation of addresses and domains (character whitelist);
client-side validation does not count as protection.
- In address-list mode every address is checked to belong to the application's
domain before it is written.
- `postfix reload` and any `exec` run **without** shell interpolation of user
input; arguments are passed as separate elements.
- Writes to config files are escaped (no injection of Postfix directives).
### Аутентификация и сессии
### Authentication and sessions
- Rate limiting на логин (по IP, с блокировкой/задержкой).
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
(`PANEL_SESSION_IDLE_DAYS`).
- Rate limiting on login (per IP, with lockout or delay).
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
`SameSite`.
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
### Вывод и процесс
### Output and process
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
группу `selfpost` и минимальные права.
- Rendering goes through `html/template` with auto-escaping (queue, log,
journal, themes).
- The panel process is **not root** (`user=panel` in supervisord); path access
is granted through the `selfpost` group with minimal permissions.
### Почтовый тракт (связанное с безопасностью)
### Mail path (security-relevant)
- **Не open relay**только SASL; `reject_unauth_destination`;
- **Not an open relay**SASL only; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
при rate-limit логина; пусто = XFF игнорируется.
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`).
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
### Резервная копия и экспорт домена
### Backup and domain export
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
текстом (иначе перенос без пересоздания кредов невозможен).
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
подменённый файл не открывается вместо тихого восстановления «хвоста».
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
шифрование по magic файла, а не по расширению.
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
аргументом (список процессов читается любым процессом контейнера).
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
offline и подбирается без ограничений по времени.
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
administrator's password hash; a domain export carries the DKIM key and
**working** application passwords in the clear (otherwise a transfer without
recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form):
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag —
a truncated or substituted file fails to open instead of silently restoring a
partial "tail". Format and wrapper:
[internal/secretfile](../internal/secretfile/secretfile.go).
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
bytes, not by extension.
- The password is never stored: without it the file cannot be recovered. In the
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
`-password-file`, never as an argument (the process list is readable by any
process in the container).
- Minimum password length matches the administrator password (12): the file
sits offline and can be attacked without a time limit.
---
## Принятые риски
## Accepted risks
Принятый риск — решение с условием возврата, а не отложенная задача из
дорожной карты.
An accepted risk is a decision with a condition for revisiting it, not a
deferred item from the roadmap.
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
панель. Ужесточение — одна строка в `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
вместо `true` в ветке «нет обоих заголовков».
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование
`html/template` и CSP, поэтому шаблоны не должны содержать
inline-скриптов и inline-стилей.
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
не хранит. Триггером сделать шифрование обязательным считать появление
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
дочитывается. Остаток — строки, delivery-строки которых потеряны
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
не может подтвердить, не должна показываться как подтверждённая, а вечный
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
читается. См. [architecture.md](architecture.md) § Log tailer.
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
`/data/log``2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
оставалась состоянием, а не диагностикой.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: every panel user (global or domain-admin) is an operator who
picks their own browser, not an untrusted party the panel needs to defend
against, and a strict mode would not "protect" such a client, it would
simply break the panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to
revisit is a requirement for protection that holds regardless of the browser,
or a domain-admin population the global administrator does not fully trust
(see the ADR below). A token would not save the panel from XSS inside it
either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why
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
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to keep a password would lose the ability to take a
backup at all, and a permanently undecryptable archive is worse than an
unencrypted one: SelfPost does not store the password. The trigger to make
encryption mandatory is a second administrator (at which point "who
downloaded it" stops being one person).
- **A journal row left without delivery lines is closed as `bounced` rather
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
`/data/log/` and survives container recreation, and the log tailer keeps its
read position (`logtail_state`, migration `0003`), so the tail is read after a
start. What remains are rows whose delivery lines are lost for good (the log
rotated past 14 files while the panel was down, or was deleted): the
reconciliation against `postqueue -p` sees the message is not in the queue and
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
the status is a false negative. Accepted deliberately: a delivery the panel
cannot confirm must not be shown as confirmed, and a permanent `queued` is
indistinguishable from "in flight right now". Reconciliation does not run
until the tailer has read the log to the end, and touches nothing if
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
tailer.
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
`postfix`) writes, the panel reads through the shared `selfpost` group, and
the file is inaccessible to others. The log holds envelope addresses and
client IPs, but neither message bodies nor headers; it is excluded from
backups (`log/` is skipped) so that a dump stays state rather than
diagnostics.
## ADR: CSRF через проверку Origin, без токенов
## ADR: CSRF via origin checking, without tokens
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
инициированного сторонним сайтом в браузере залогиненного админа.
**Context.** The panel is forms (`POST`) with a cookie session — the classic
CSRF surface. What is needed is a way to tell a request from the panel's own
page apart from one initiated by a third-party site in a logged-in user's
browser. The panel is multi-user since 1.2.0 (a global administrator plus
zero or more domain-admin users, each scoped to their assigned domains), but
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.
**Решение.** `originAllowed` в
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
сессии и встроенных в формы, нет.
**Decision.** `originAllowed` in
[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
request carrying neither header is **allowed through** rather than rejected.
There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role.
**Почему не токены.** Панель однопользовательская (один администратор на
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
обороны, не CSRF-токен.
**Why not tokens.** Cross-user CSRF is not the threat model here: a
domain-admin's browser sending a request still needs that domain-admin's own
cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
don't already enforce. The remaining case is an external site making a
logged-in user's browser send a request, which the origin check covers without
touching a single template.
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.
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
(по-настоящему старый браузер или webview с замороженным движком), остаётся
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
панель в таком клиенте ценой узкой остаточной поверхности.
**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
"Accepted risks" above. This is a deliberate choice not to break the panel in
such a client, at the price of a narrow residual surface.
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
браузера, или панель станет многопользовательской.
**Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or domain-admin accounts stop being trusted operators (for
example, if a future release lets a global administrator invite domain-admins
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
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
Третьего варианта («посмотрели и ладно») нет.
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
closes every finding in one of two ways: a fix before the tag, or an entry here
with its rationale and its condition for revisiting, like the items above.
There is no third option ("we looked at it and moved on").
+1 -2
View File
@@ -12,8 +12,7 @@ import (
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент
// saslpasswd2", per the plan).
// created and removed with the standard saslpasswd2 tool.
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses
+14 -4
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
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
// 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 {
a, err := s.store.DeleteApplication(id)
a, err := s.store.GetApplication(id)
if err != nil {
return err
}
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
if _, err := s.store.DeleteApplication(id); err != nil {
return err
}
// Drop the application's level-2 limit, if any (guide § Rate limiting);
// rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
@@ -189,9 +199,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
}
// SaveRateLimit stores the application-level rate limit. The caller has validated
// the IPs and numbers (security.md); the milter reads the row live, so no reload
// is needed.
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
// limiting). The caller has validated the IPs and numbers (security.md); the
// milter reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeApp,
+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) {
svc, st, _, maps := newServiceHarness(t)
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
// queue are deliberately excluded (architecture.md § Persistence).
//
// Restore is intentionally not a separate code path: a backup is extracted
// into the /data bind mount before first start, and the panel regenerates
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal
// start. The only restore-specific step is CheckRestore, which refuses to boot
// if the manifest's version does not match the running binary, so
// Restore is not a separate code path in the panel: a backup is extracted into
// the /data bind mount before first start, and the archive already carries
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender
// map — so the daemons can start on the extracted files alone. The
// 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
// (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
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
// 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
// never blocks a later in-place image upgrade. Absence of the manifest is the
// normal case and returns nil.
func CheckRestore(manifestPath, binaryVersion string) error {
// never blocks a later in-place image upgrade, and restored is true so the
// caller can heal drifted daemon maps once. Absence of the manifest is the
// normal case and returns restored == false with a nil error.
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
return nil // ordinary start, not a restore
return false, nil // ordinary start, not a restore
}
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
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 {
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 {
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)",
m.Version, binaryVersion, m.Version)
}
// Version matches: consume the manifest so subsequent normal starts (and
// in-place upgrades) are not gated by it.
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) {
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)
}
if restored {
t.Error("CheckRestore with no manifest reported a restore")
}
}
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir()
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)
}
if !restored {
t.Fatal("CheckRestore matching did not report a restore")
}
if _, err := os.Stat(path); !os.IsNotExist(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) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
err := CheckRestore(path, "2.0.0")
restored, err := CheckRestore(path, "2.0.0")
if err == nil {
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") {
t.Errorf("error should name both versions: %v", err)
}
@@ -190,7 +201,11 @@ func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir()
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")
}
if restored {
t.Error("CheckRestore wrong format reported a restore")
}
}
+12 -3
View File
@@ -60,6 +60,7 @@ type Domain struct {
DKIM Result
SPF Result
DMARC Result
DMARCReportAuth Result // zero when external rua= is not used
Overall health.Status
CheckedAt time.Time
}
@@ -74,6 +75,7 @@ type Query struct {
ExpectedDKIM string
Hostname string
ServerIPs []string
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
}
// resolver is the slice of *net.Resolver this package uses, as an interface so
@@ -178,12 +180,19 @@ func (c *Checker) Forget(domainName string) {
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
d := Domain{Name: q.Name, CheckedAt: time.Now()}
var wg sync.WaitGroup
wg.Add(3)
wg.Add(4)
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
go func() {
defer wg.Done()
hub := EmailDomain(q.DMARCReportEmail)
if hub != "" && !strings.EqualFold(hub, q.Name) {
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
}
}()
wg.Wait()
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
return d
}
+19 -2
View File
@@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) {
txt["_dmarc.example.com"] = c.record
}
f := &fakeResolver{txt: txt}
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
if got.Status != c.want {
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
}
@@ -303,7 +303,7 @@ func TestDMARC(t *testing.T) {
func TestDMARCNonePolicyIsExplained(t *testing.T) {
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
if !strings.Contains(got.Detail, "monitoring only") {
t.Errorf("p=none is not explained: %s", got.Detail)
}
@@ -357,3 +357,20 @@ func TestForgetDropsTheCachedDomain(t *testing.T) {
t.Error("Forget did not drop the cached result")
}
}
func TestReportAuth(t *testing.T) {
f := &fakeResolver{txt: map[string][]string{"_report._dmarc.hub.example": {"v=DMARC1;"}}}
got := newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
if got.Status != health.StatusOK {
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
}
f = &fakeResolver{}
got = newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
if got.Status != health.StatusWarn {
t.Fatalf("missing = %q, want warn", got.Status)
}
if !strings.Contains(got.Detail, ReportAuthExample()) {
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
}
}
+48 -6
View File
@@ -1,6 +1,7 @@
package dnscheck
import (
"database/sql"
"net"
"strings"
)
@@ -16,6 +17,13 @@ import (
// helper: it is published at the domain itself.)
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
// ReportAuthRecordName is where a report-receiving domain authorises external
// DMARC aggregate destinations (RFC 7489 §7.1).
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
func ReportAuthExample() string { return "v=DMARC1;" }
// SPFExample is the SPF record this server expects for a sending domain: the
// addresses its mail actually leaves from, and "-all" to say that nothing else
// is authorised. When the server's own addresses are not known (its hostname
@@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string {
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
}
// DMARCExample is the least a domain should publish: monitoring only, with an
// address the aggregate reports go to. p=none is deliberate — it changes
// nothing about delivery, so it is safe to publish before the reports have
// shown that DKIM and SPF pass everywhere.
func DMARCExample(domainName string) string {
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
// override wins, then the administrator profile, then policy-only (empty).
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
if domainRua.Valid {
return domainRua.String
}
return profileEmail
}
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
func EmailDomain(addr string) string {
addr = strings.TrimSpace(addr)
at := strings.LastIndex(addr, "@")
if at < 0 || at == len(addr)-1 {
return ""
}
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
}
// DMARCExample is the DMARC TXT record this server suggests for a sending
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
// included only when reportEmail is set; SelfPost is send-only and most
// operators have no inbox on the sending domain itself.
func DMARCExample(reportEmail string) string {
base := "v=DMARC1; p=none"
if reportEmail == "" {
return base
}
return base + "; rua=mailto:" + reportEmail
}
// ExternalReportAuth reports whether the hub domain must publish a
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
// sendingDomain.
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
hub := EmailDomain(reportEmail)
if hub == "" || strings.EqualFold(hub, sendingDomain) {
return "", "", false
}
return ReportAuthRecordName(hub), ReportAuthExample(), true
}
+38 -4
View File
@@ -2,6 +2,7 @@ package dnscheck
import (
"context"
"database/sql"
"strings"
"testing"
@@ -18,8 +19,6 @@ func TestSPFExample(t *testing.T) {
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
// The hostname does not resolve, so there is no address to name; an "a:"
// mechanism still gives the operator a publishable record.
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
}
@@ -32,6 +31,41 @@ func TestSPFExample(t *testing.T) {
}
}
func TestDMARCExample(t *testing.T) {
if got := DMARCExample(""); got != "v=DMARC1; p=none" {
t.Errorf("empty = %q", got)
}
want := "v=DMARC1; p=none; rua=mailto:reports@hub.example"
if got := DMARCExample("reports@hub.example"); got != want {
t.Errorf("with rua = %q, want %q", got, want)
}
}
func TestResolveDMARCRua(t *testing.T) {
inherit := sql.NullString{}
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
t.Errorf("inherit profile = %q", got)
}
none := sql.NullString{Valid: true}
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
t.Errorf("explicit none = %q", got)
}
custom := sql.NullString{Valid: true, String: "x@y.com"}
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
t.Errorf("custom = %q", got)
}
}
func TestExternalReportAuth(t *testing.T) {
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
t.Fatalf("external = (%q, %q, %v)", name, value, ok)
}
if _, _, ok := ExternalReportAuth("shop.com", "dmarc@shop.com"); ok {
t.Fatal("same domain should not need external auth")
}
}
// The record the panel shows and the one a failed check suggests must be the
// same string, or the operator is told two different things on one page.
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
@@ -50,11 +84,11 @@ func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
}
dmarc := c.checkDMARC(context.Background(), "example.com")
dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"})
if dmarc.Status != health.StatusWarn {
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
}
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
if want := DMARCExample(""); !strings.Contains(dmarc.Detail, want) {
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
}
}
+54 -5
View File
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
// required for delivery, so its absence is advice (warn), not a fault.
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
name := DMARCRecordName(domainName)
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
name := DMARCRecordName(q.Name)
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC record", err)
}
example := DMARCExample(q.DMARCReportEmail)
var records []string
for _, rec := range txt {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
}
}
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
if q.DMARCReportEmail == "" {
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
}
return Result{Status: health.StatusWarn, Detail: detail}
}
if len(records) > 1 {
return Result{
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
return Result{Status: health.StatusOK, Detail: detail, Records: records}
}
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
// aggregate-report destinations. Missing authorisation does not affect outbound
// delivery, only whether reports reach the rua= mailbox.
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
name := ReportAuthRecordName(hubDomain)
expected := ReportAuthExample()
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC report-authorisation record", err)
}
var records []string
for _, rec := range txt {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
records = append(records, rec)
}
}
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
}
}
if len(records) > 1 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
Records: records,
}
}
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
Records: records,
}
}
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
// reports. It is used on the settings page for the administrator profile.
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
if hubDomain == "" {
return Result{}
}
return c.checkReportAuth(ctx, hubDomain)
}
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
// line breaks into the base64, none of which are part of the key.
+8
View File
@@ -167,6 +167,14 @@ func (o *OpenDKIM) Reload() error {
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
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written
+3 -3
View File
@@ -145,13 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
}
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// IPs and numbers (security.md); the milter reads the row live, so no reload is
// needed.
// numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
AllowedIPs: ips,
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
+14
View File
@@ -1,6 +1,7 @@
package domain
import (
"database/sql"
"fmt"
"github.com/mixeme/selfpost/internal/buildinfo"
@@ -23,6 +24,7 @@ type DomainExport struct {
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
Applications []AppExport `json:"applications"`
}
@@ -59,6 +61,10 @@ func (s *Service) Export(id int64) (DomainExport, error) {
DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)),
}
if d.DMARCRua.Valid {
s := d.DMARCRua.String
exp.DMARCRua = &s
}
for _, a := range apps {
password, err := s.apps.Secret(a.Login)
if err != nil {
@@ -110,6 +116,14 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
return store.Domain{}, err
}
if exp.DMARCRua != nil {
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
}
for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID)
+10
View File
@@ -1,6 +1,7 @@
package domain
import (
"database/sql"
"errors"
"fmt"
"testing"
@@ -72,6 +73,9 @@ func TestExportImportRoundTrip(t *testing.T) {
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
t.Fatalf("set dmarc rua: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
@@ -107,6 +111,12 @@ func TestExportImportRoundTrip(t *testing.T) {
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
}
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
}
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
if err != nil {
+100 -35
View File
@@ -62,10 +62,14 @@ type CPU struct {
// nothing until it is true.
Measured bool
BusyPct float64
// Cores and Threads are physical cores and logical CPUs (SMT). Both come
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
// the cpuN lines in /proc/stat and Cores matches Threads.
Cores int
Threads int
// Load is the 1/5/15-minute load average, present when /proc/loadavg
// could be read. Unlike BusyPct it needs no previous sample, so it is
// there on the very first page load.
// could be read. Unlike BusyPct it needs no previous sample. It is kept
// for the sampler but is no longer shown on the Status card.
Load [3]float64
HasLoad bool
Status Status
@@ -176,7 +180,14 @@ type netCounters struct {
func (m *MachineSampler) Sample() Machine {
root := m.root()
now := time.Now()
cpuNow, cores, cpuErr := readCPUTimes(root)
cpuNow, logical, cpuErr := readCPUTimes(root)
cores, threads := readCPUTopology(root)
if threads == 0 {
threads = logical
}
if cores == 0 {
cores = threads
}
netNow, netErr := readNetDev(root)
m.mu.Lock()
@@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine {
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
mach := Machine{
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
Memory: readMemory(root),
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
}
@@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine {
return mach
}
// cpuUsage grades the processor over the window. The load average is reported
// alongside it because the two answer different questions — how busy the
// processor was, and how many tasks were waiting for it — and a machine can
// look idle while work queues up behind a slow disk.
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
c := CPU{Cores: cores}
// cpuUsage grades the processor over the window. Detail carries only the
// core and thread counts — load average and busy prose stay out of the Status
// card (see roadmap panel-docs for operator-facing explanation later).
func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
c := CPU{Cores: cores, Threads: threads}
if !isZeroLoad(load) {
c.Load, c.HasLoad = load, true
}
@@ -223,9 +233,9 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
return c
}
c.Detail = c.contextText()
if !fresh || cur.total <= prev.total {
c.Status = StatusUnknown
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
return c
}
@@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
if c.BusyPct >= cpuWarnPct {
c.Status = StatusWarn
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
} else {
c.Status = StatusOK
c.Detail = c.contextText()
}
return c
}
// contextText is the CPU's supporting figures: what the percentage is a
// percentage of, and how deep the run queue is.
// contextText is the CPU detail column: physical cores and logical threads.
func (c CPU) contextText() string {
var parts []string
if c.Cores > 0 {
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
switch {
case c.Cores > 0 && c.Threads > 0:
return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
case c.Threads > 0:
return fmt.Sprintf("%d threads", c.Threads)
case c.Cores > 0:
return fmt.Sprintf("%d cores", c.Cores)
default:
return ""
}
if c.HasLoad {
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
}
return strings.Join(parts, " · ")
}
// readMemory reports main memory from /proc/meminfo. Used is derived from
@@ -296,8 +305,7 @@ func readMemory(root string) Memory {
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
}
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
if m.SwapTotalBytes > 0 {
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
}
@@ -418,6 +426,74 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
return times, cores, nil
}
// readCPUTopology returns physical core and logical thread counts from
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
// back to the cpuN count from /proc/stat.
func readCPUTopology(root string) (cores, threads int) {
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
if err != nil {
return 0, 0
}
type coreKey struct{ phys, core int }
seen := map[coreKey]bool{}
var (
inCPU bool
phys = -1
core = -1
idx int
)
flush := func() {
if !inCPU {
return
}
p, c := phys, core
if p < 0 {
p = idx
}
if c < 0 {
c = idx
}
seen[coreKey{p, c}] = true
inCPU, phys, core = false, -1, -1
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
flush()
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "processor":
flush()
inCPU = true
threads++
idx = threads - 1
if n, err := strconv.Atoi(val); err == nil {
idx = n
}
case "physical id":
if n, err := strconv.Atoi(val); err == nil {
phys = n
}
case "core id":
if n, err := strconv.Atoi(val); err == nil {
core = n
}
}
}
flush()
if threads == 0 {
return 0, 0
}
return len(seen), threads
}
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
// /proc/loadavg simply has none reported, so the failure is a zero value rather
// than an error.
@@ -544,14 +620,3 @@ func humanRate(perSec float64) string {
}
return humanBytes(uint64(perSec+0.5)) + "/s"
}
// joinDetail joins the non-empty parts of a detail line.
func joinDetail(parts ...string) string {
var kept []string
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " ")
}
+45 -10
View File
@@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
// 1000 ticks pass, 250 of them idle: 75% busy.
prev := cpuTimes{total: 10000, idle: 8000}
cur := cpuTimes{total: 11000, idle: 8250}
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil)
if !got.Measured {
t.Fatalf("reading not marked measured: %+v", got)
}
@@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
if got.Status != StatusOK {
t.Errorf("status = %q, want ok", got.Status)
}
if got.Cores != 4 || !got.HasLoad {
t.Errorf("cores/load not reported: %+v", got)
if got.Cores != 4 || got.Threads != 8 || !got.HasLoad {
t.Errorf("cores/threads/load not reported: %+v", got)
}
if got.Detail != "4 cores · 8 threads" {
t.Errorf("detail = %q, want cores and threads only", got.Detail)
}
}
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, 1, [3]float64{}, true, nil)
if got.Status != StatusWarn {
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
}
if got.Detail != "1 cores · 1 threads" {
t.Errorf("detail = %q, want topology only (no warn prose)", got.Detail)
}
if got.HasLoad {
t.Error("a missing load average should not be reported as zeros")
}
@@ -84,17 +90,44 @@ func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
} {
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
got := cpuUsage(c.prev, c.cur, 2, 2, [3]float64{}, c.fresh, nil)
if got.Measured || got.Status != StatusUnknown {
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
}
}
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
}
}
func TestReadCPUTopology(t *testing.T) {
dir := t.TempDir()
const cpuinfo = `processor : 0
physical id : 0
core id : 0
processor : 1
physical id : 0
core id : 0
processor : 2
physical id : 0
core id : 1
processor : 3
physical id : 0
core id : 1
`
if err := os.WriteFile(filepath.Join(dir, "cpuinfo"), []byte(cpuinfo), 0o600); err != nil {
t.Fatal(err)
}
cores, threads := readCPUTopology(dir)
if cores != 2 || threads != 4 {
t.Errorf("topology = %d cores / %d threads, want 2/4", cores, threads)
}
}
func TestReadMemory(t *testing.T) {
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
got := readMemory(dir)
@@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) {
if got.TotalBytes != 4*1024*1024*1024 {
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
}
// 4 GiB total, 2 GiB available to new work: half used, cache included in
// what is available.
// 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available).
if got.Percent() != 50 {
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
}
@@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
if !first.Memory.Measured {
t.Error("memory is a level, not a rate: it must be reported on the first sample")
}
if first.CPU.Cores != 2 {
t.Errorf("cores = %d, want 2", first.CPU.Cores)
if first.CPU.Cores != 2 || first.CPU.Threads != 2 {
t.Errorf("cores/threads = %d/%d, want 2/2", first.CPU.Cores, first.CPU.Threads)
}
if first.CPU.Detail != "2 cores · 2 threads" {
t.Errorf("detail = %q, want topology only", first.CPU.Detail)
}
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
t.Errorf("load average not read: %+v", first.CPU.Load)
+1 -1
View File
@@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket {
default:
s.Present = true
s.Status = StatusOK
s.Detail = "Listening."
s.Detail = "Listening"
}
return s
}
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+23
View File
@@ -0,0 +1,23 @@
// Package legal embeds SelfPost's AGPL-3.0 licence text so the panel can
// serve it at /license (Appropriate Legal Notices) without depending on
// GitHub or a file on disk at runtime.
//
// The copy in this directory must stay identical to the repository-root
// LICENSE; license_test.go enforces that.
package legal
import (
_ "embed"
)
//go:embed LICENSE
var License []byte
// SourceURL is where Corresponding Source for the published upstream
// releases lives. It is injected into the panel footer by view.go.
// 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"
// CopyrightLine is the short copyright notice shown in the panel footer.
const CopyrightLine = "Copyright © 2026 Mikhail Yenuchenko"
+40
View File
@@ -0,0 +1,40 @@
package legal
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
root, err := os.ReadFile(filepath.Join("..", "..", "LICENSE"))
if err != nil {
t.Fatalf("read root LICENSE: %v", err)
}
if !bytes.Equal(root, License) {
t.Fatal("internal/legal/LICENSE differs from the repository-root LICENSE; copy the root file over")
}
if len(License) == 0 {
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{}
}
// 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
// window (at or after since), pruning any that outlived reservationTTL.
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()
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]
cutoff := time.Now().Add(-reservationTTL)
var n int64
@@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 {
return n
}
// reserve claims a slot for key until the message is recorded or released.
func (f *inflight) reserve(key string) *reservation {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
// reserveLocked is reserve's body; the caller holds mu.
func (f *inflight) reserveLocked(key string) *reservation {
if f.m == nil {
f.m = make(map[string]map[*reservation]struct{})
}
+194 -24
View File
@@ -3,6 +3,7 @@ package milter
import (
"errors"
"net"
"sync"
"testing"
"time"
@@ -15,7 +16,10 @@ import (
// 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
// 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 {
mu sync.Mutex
entries []store.SendLogEntry
rejected []store.SendLogEntry
fail bool
@@ -27,17 +31,25 @@ type fakeRecorder struct {
counts map[string]int64
lookupErr 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 {
if f.fail {
return errors.New("boom")
}
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, e)
return nil
}
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
f.mu.Lock()
defer f.mu.Unlock()
f.rejected = append(f.rejected, e)
return nil
}
@@ -47,6 +59,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
return store.RateLimit{}, false, f.lookupErr
}
rl, ok := f.limits[scope+"|"+ref]
if ok {
rl.Scope = scope
}
return rl, ok, nil
}
@@ -54,6 +69,9 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
if f.countErr != nil {
return 0, f.countErr
}
if f.onCount != nil {
f.onCount()
}
return f.counts[scope+"|"+ref], nil
}
@@ -186,11 +204,14 @@ func TestBracedMacros(t *testing.T) {
}
}
// limitAt is the client IP the rate-limit tests connect from; the limits below
// register it so the differentiated check applies.
// limitIP is the client IP rate-limit tests connect from.
const limitIP = "203.0.113.7"
func activeLimit(ips ...string) store.RateLimit {
func domainLimit() store.RateLimit {
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
}
func appLimit(ips ...string) store.RateLimit {
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
}
@@ -212,7 +233,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
}
@@ -227,7 +248,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
}
@@ -239,7 +260,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
func TestRateLimitAllowsUnderLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
}
@@ -251,25 +272,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
}
}
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
// The sender's IP is not in the domain's registered set, so level-2 does not
// apply even though the count is huge (level-1 anvil would still cover it).
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
// are ignored.
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
}
}
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
},
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 5, // over domain
store.RateLimitScopeApp + "|app1": 2, // under app
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
}
}
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
},
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 1,
store.RateLimitScopeApp + "|app1": 0,
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
}
}
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 1,
store.RateLimitScopeApp + "|app1": 0,
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
}
}
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{
// IP registered but no ceiling/window: an inert draft, must not enforce.
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
@@ -288,7 +360,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
countErr: errors.New("db down"),
}
@@ -300,7 +372,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
@@ -333,7 +405,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
}
@@ -358,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
// must be given back — otherwise it would be counted twice and the ceiling
// would drift closed.
@@ -395,17 +519,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
}
}
// A refused message must not leave the slots it claimed for the limits checked
// before the one that tripped, or every refusal would tighten the ceiling.
func TestRefusalReleasesEarlierReservation(t *testing.T) {
// A trusted app at its ceiling refuses without touching the domain counter;
// no domain reservation should linger after the refusal.
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
store.RateLimitScopeDomain + "|example.com": 0,
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
},
}
fl := &inflight{}
@@ -415,6 +539,49 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("domain reservation left behind after refusal: %d", n)
}
if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("app reservation left behind after refusal: %d", n)
}
}
// 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
@@ -422,7 +589,10 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
// counted against a window it no longer belongs to.
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
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)
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
+39 -34
View File
@@ -8,9 +8,11 @@ import (
)
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (guide § Rate limiting). It
// checks the domain-level and application-level limits in turn; either being
// exceeded is enough to refuse.
// refused under a level-2 differentiated limit (guide § Rate limiting).
//
// Trusted application IPs (app limit active and client IP listed) use only the
// app ceiling and skip the domain check. Everyone else is under the domain
// ceiling when one is configured; otherwise only level 1 applies.
//
// It is deliberately fail-open: any store error, or the absence of a usable
// limit, is treated as "not over limit" so a malfunction of the level-2
@@ -26,46 +28,49 @@ func (s *session) overLimit() bool {
if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply
}
checks := []struct{ scope, ref string }{
{store.RateLimitScopeDomain, domainOf(s.from)},
{store.RateLimitScopeApp, s.login},
}
var taken []*reservation
for _, c := range checks {
if c.ref == "" {
continue
}
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
if s.login != "" {
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
if err != nil {
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
}
// No limit configured, an inert draft, or a client IP outside the
// registered set: the differentiated limit does not apply here.
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
continue
}
domain := domainOf(s.from)
if domain == "" {
return false
}
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain)
if err != nil {
log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err)
return false
}
if !ok || !rl.Active() {
return false
}
return s.enforceLimit(store.RateLimitScopeDomain, domain, rl)
}
// enforceLimit counts recent messages for scope/ref and refuses when at or
// 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 {
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
n, err := s.rec.CountMessages(c.scope, c.ref, since)
stored, err := s.rec.CountMessages(scope, ref, since)
if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
return false
}
key := c.scope + "|" + c.ref
n += s.flight.count(key, since)
if n >= int64(rl.MaxMessages) {
r, n, ok := s.flight.tryAdmit(scope+"|"+ref, since, stored, int64(rl.MaxMessages))
if !ok {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
// The message is refused, so the slots claimed for the limits
// checked before this one must not stay claimed.
for _, r := range taken {
s.flight.release(r)
}
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
return true
}
taken = append(taken, s.flight.reserve(key))
}
s.reserved = append(s.reserved, taken...)
s.reserved = append(s.reserved, r)
return false
}
+8
View File
@@ -75,6 +75,14 @@ func (p *Postfix) Reload() error {
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
// 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
-84
View File
@@ -1,84 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (security.md).
type Admin struct {
Username string
PasswordHash string
CreatedAt time.Time
}
// AdminExists reports whether the administrator account has been created. This
// doubles as the "primary setup complete" flag: once true, the /setup route is
// permanently gone (security.md).
func (s *Store) AdminExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
return false, fmt.Errorf("count admin: %w", err)
}
return n > 0, nil
}
// CreateAdmin inserts the administrator row. It fails if one already exists,
// which — combined with the id=1 constraint — makes admin creation one-shot
// even under a race between two setup submissions.
func (s *Store) CreateAdmin(username, passwordHash string) error {
_, err := s.db.Exec(
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("create admin: %w", err)
}
return nil
}
// UpdateAdmin replaces the administrator's username and password hash. It
// touches only the admin row (id = 1): panel credentials are unrelated to the
// SASL logins applications authenticate with, which live in their own table.
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
// silently create an account.
func (s *Store) UpdateAdmin(username, passwordHash string) error {
res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
username, passwordHash,
)
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
if n == 0 {
return ErrNoAdmin
}
return nil
}
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
func (s *Store) GetAdmin() (Admin, error) {
var (
a Admin
createdAt string
)
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
Scan(&a.Username, &a.PasswordHash, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Admin{}, ErrNoAdmin
}
if err != nil {
return Admin{}, fmt.Errorf("get admin: %w", err)
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
-45
View File
@@ -1,45 +0,0 @@
package store
import (
"errors"
"testing"
)
func TestUpdateAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
t.Fatalf("UpdateAdmin: %v", err)
}
a, err := st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-two" {
t.Fatalf("unexpected admin after update: %+v", a)
}
if a.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
// An update before setup must not create the account: only the one-time setup
// flow may do that (security.md).
func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
}
exists, err := st.AdminExists()
if err != nil {
t.Fatalf("AdminExists: %v", err)
}
if exists {
t.Fatal("UpdateAdmin created an administrator")
}
}
+48 -3
View File
@@ -24,6 +24,7 @@ type Domain struct {
ID int64
Name string
DKIMSelector string
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
CreatedAt time.Time
AppCount int
}
@@ -54,7 +55,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
// ordered by name.
func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
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
ORDER BY d.name`)
@@ -74,11 +75,37 @@ func (s *Store) ListDomains() ([]Domain, error) {
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
// ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
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
WHERE d.id = ?`, id)
@@ -120,7 +147,7 @@ func scanDomain(r scanRow) (Domain, error) {
d Domain
createdAt string
)
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
return Domain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
@@ -137,3 +164,21 @@ func isUniqueViolation(err error) bool {
}
return false
}
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
// NULL means inherit the administrator profile; Valid with an empty string means
// policy-only with no aggregate reports for this domain.
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
if err != nil {
return fmt.Errorf("update domain dmarc rua: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update domain dmarc rua rows: %w", err)
}
if n == 0 {
return ErrDomainNotFound
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"database/sql"
"testing"
)
func TestDomainDMARCRua(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "sel")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
got, err := st.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
if got.DMARCRua.Valid {
t.Fatal("new domain should inherit profile")
}
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil {
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
}
got, err = st.GetDomain(d.ID)
if err != nil || got.DMARCRua.String != "reports@hub.com" {
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
}
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
}
got, _ = st.GetDomain(d.ID)
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
t.Fatalf("none = %+v", got.DMARCRua)
}
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
}
got, _ = st.GetDomain(d.ID)
if got.DMARCRua.Valid {
t.Fatalf("inherit = %+v", got.DMARCRua)
}
}
@@ -0,0 +1,5 @@
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
@@ -0,0 +1,26 @@
-- Panel users and domain-admin assignments. Migrates the single admin row into
-- a global user; drops the admin table.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
dmarc_report_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE user_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, domain_id)
);
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
SELECT username, password_hash, 'global', dmarc_report_email, created_at
FROM admin WHERE id = 1;
INSERT OR REPLACE INTO settings (key, value)
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
DROP TABLE admin;
+24 -20
View File
@@ -18,36 +18,40 @@ const (
)
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
// an optional set of expected client IPs plus a message ceiling over a sliding
// window, attached to a domain or an application. It is enforced in the
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
// IP backstop that always applies even when this is absent or the milter is
// down.
// a message ceiling over a sliding window, attached to a domain or an
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
// architecture.md § Mail path) is the IP backstop that always applies even
// when this is absent or the milter is down.
//
// Both the IP binding and the ceiling are optional in the schema, but a limit
// is only enforced when it is Active(): the design deliberately allows an
// admin to leave the IP binding empty for apps that send from changing IPs, in
// which case only level 1 protects them (guide § Rate limiting).
// Domain limits apply to every client IP once max and window are set. Application
// limits additionally require AllowedIPs: those trusted addresses get the app
// ceiling (above the domain) and skip the domain check; other IPs stay under
// the domain limit or level 1 alone (guide § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // canonical client IPs this limit applies to
AllowedIPs []string // trusted client IPs for an application override
MaxMessages int
WindowSeconds int
}
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
// known sender.
// Domain: max and window only. Application: also needs at least one trusted IP
// (the privilege that raises the ceiling above the domain).
func (r RateLimit) Active() bool {
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
return false
}
if r.Scope == RateLimitScopeApp {
return len(r.AllowedIPs) > 0
}
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
return true
}
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
// comparison parses both sides so equivalent textual forms of the same address
// match; a client IP outside the list means the differentiated limit does not
// apply to it (level 1 still does).
// AllowsIP reports whether ip is one of the application's trusted client IPs.
// Used only for application overrides; domain limits do not consult this list.
// Equivalent textual forms of the same address match.
func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip)
if c == nil {
@@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
}
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
// (an IP-only draft) by leaving the corresponding field zero, which makes the
// limit inert via Active().
// by leaving the corresponding field zero, which makes the limit inert via
// Active() until max and window are both set.
func scanRateLimit(r scanRow) (RateLimit, error) {
var (
ips sql.NullString
+17 -9
View File
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{
{},
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
if !active.Active() {
t.Fatalf("should be active: %+v", active)
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
if !domainActive.Active() {
t.Fatalf("domain without IPs should be active: %+v", domainActive)
}
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
appActive := RateLimit{
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
MaxMessages: 5, WindowSeconds: 60,
}
if !appActive.Active() {
t.Fatalf("should be active: %+v", appActive)
}
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
t.Fatalf("registered IPs should match")
}
// Equivalent textual form of the IPv6 address must still match.
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
}
}
+30 -2
View File
@@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
return row, nil
}
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
// application login. An empty field matches everything.
// SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of
// 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 {
Domain string
AppLogin string
Domains []string
AllDomains bool
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// 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) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
@@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string
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 != "" {
clauses = append(clauses, "domain = ?")
args = append(args, f.Domain)
+57
View File
@@ -1,6 +1,8 @@
package store
import (
"sort"
"strings"
"testing"
"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) {
st := openTestStore(t)
+32
View File
@@ -0,0 +1,32 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// GetSetting returns a settings value or empty string when missing.
func (s *Store) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get setting %s: %w", key, err)
}
return value, nil
}
// SetSetting upserts a settings key.
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
key, value,
)
if err != nil {
return fmt.Errorf("set setting %s: %w", key, err)
}
return nil
}
+4 -3
View File
@@ -1,7 +1,8 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists the administrator account, sending domains and applications,
// the send log and rate-limit settings (architecture.md § Persistence). It
// exposes typed queries so the rest of the panel never builds SQL by hand.
// that persists panel users (global administrators and domain-admins), sending
// domains and applications, the send log and rate-limit settings
// (architecture.md § Persistence). It exposes typed queries so the rest of the
// panel never builds SQL by hand.
package store
import (
+364
View File
@@ -0,0 +1,364 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoUser is returned when primary setup has not happened yet.
var ErrNoUser = errors.New("no panel user")
// ErrUserNotFound is returned when a panel user id or username does not exist.
var ErrUserNotFound = errors.New("user not found")
// ErrUserExists is returned when a username is already taken.
var ErrUserExists = errors.New("username already taken")
// ErrLastGlobal is returned when deleting or demoting the last global user.
var ErrLastGlobal = errors.New("cannot remove last global administrator")
// Role identifies a panel user's access level.
type Role string
const (
RoleGlobal Role = "global"
RoleDomainAdmin Role = "domain_admin"
)
// User is a panel login (not an application SASL account).
type User struct {
ID int64
Username string
PasswordHash string
Role Role
DMARCReportEmail string
CreatedAt time.Time
DomainIDs []int64
}
// UserExists reports whether any panel user exists (setup complete).
func (s *Store) UserExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n); err != nil {
return false, fmt.Errorf("count users: %w", err)
}
return n > 0, nil
}
// CreateGlobalUser inserts the first global user during setup.
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
exists, err := s.UserExists()
if err != nil {
return err
}
if exists {
return fmt.Errorf("create global user: users already exist")
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
username, passwordHash, RoleGlobal, now,
)
if err != nil {
return fmt.Errorf("create global user: %w", err)
}
return nil
}
// GetUserByUsername returns a user with domain assignments loaded.
func (s *Store) GetUserByUsername(username string) (User, error) {
var (
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("get user by username: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
if err != nil {
return User{}, err
}
return u, nil
}
// GetUser returns a user by id with domain assignments.
func (s *Store) GetUser(id int64) (User, error) {
var (
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
id,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("get user: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
if err != nil {
return User{}, err
}
return u, nil
}
// ListUsers returns every panel user without domain ids.
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var (
u User
createdAt string
)
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
return nil, fmt.Errorf("list users scan: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
users = append(users, u)
}
return users, rows.Err()
}
// UserRow is a user plus assigned domain names for the management list.
type UserRow struct {
User User
DomainNames []string
}
// ListUserRows returns users with assigned domain names for the management UI.
func (s *Store) ListUserRows() ([]UserRow, error) {
users, err := s.ListUsers()
if err != nil {
return nil, err
}
rows := make([]UserRow, len(users))
for i, u := range users {
rows[i].User = u
if u.Role == RoleGlobal {
continue
}
names, err := s.listUserDomainNames(u.ID)
if err != nil {
return nil, err
}
rows[i].DomainNames = names
}
return rows, nil
}
// CountGlobalUsers returns how many global-role users exist.
func (s *Store) CountGlobalUsers() (int, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = ?", RoleGlobal).Scan(&n); err != nil {
return 0, fmt.Errorf("count global users: %w", err)
}
return n, nil
}
// CreateUser inserts a panel user and optional domain assignments.
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
if role == RoleDomainAdmin && len(domainIDs) == 0 {
return 0, fmt.Errorf("create user: domain_admin requires domains")
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
username, passwordHash, role, now,
)
if err != nil {
if isUniqueViolation(err) {
return 0, ErrUserExists
}
return 0, fmt.Errorf("create user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create user id: %w", err)
}
if role == RoleDomainAdmin {
if err := s.setUserDomains(id, domainIDs); err != nil {
return 0, err
}
}
return id, nil
}
// UpdateUser replaces username, password hash, and DMARC email for a user.
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
u, err := s.GetUser(id)
if err != nil {
return err
}
res, err := s.db.Exec(
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
username, passwordHash, dmarcReportEmail, id,
)
if err != nil {
if isUniqueViolation(err) {
return ErrUserExists
}
return fmt.Errorf("update user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update user: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
if u.Role == RoleGlobal {
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
return err
}
}
return nil
}
// SetUserRole updates a user's role.
func (s *Store) SetUserRole(userID int64, role Role) error {
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
if err != nil {
return fmt.Errorf("set user role: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set user role: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// ClearUserDomains removes all domain assignments for a user.
func (s *Store) ClearUserDomains(userID int64) error {
_, err := s.db.Exec("DELETE FROM user_domains WHERE user_id = ?", userID)
if err != nil {
return fmt.Errorf("clear user domains: %w", err)
}
return nil
}
// SetUserDomains replaces domain assignments for a domain_admin user.
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
u, err := s.GetUser(userID)
if err != nil {
return err
}
if u.Role != RoleDomainAdmin {
return fmt.Errorf("set user domains: user is not domain_admin")
}
if len(domainIDs) == 0 {
return fmt.Errorf("set user domains: at least one domain required")
}
return s.setUserDomains(userID, domainIDs)
}
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global user.
func (s *Store) DeleteUser(id int64) error {
u, err := s.GetUser(id)
if err != nil {
return err
}
if u.Role == RoleGlobal {
n, err := s.CountGlobalUsers()
if err != nil {
return err
}
if n <= 1 {
return ErrLastGlobal
}
}
res, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
func (s *Store) GlobalDMARCReportEmail() (string, error) {
return s.GetSetting("dmarc_report_email")
}
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
rows, err := s.db.Query("SELECT domain_id FROM user_domains WHERE user_id = ? ORDER BY domain_id", userID)
if err != nil {
return nil, fmt.Errorf("list user domains: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("list user domains scan: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) listUserDomainNames(userID int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT d.name FROM user_domains ud JOIN domains d ON d.id = ud.domain_id WHERE ud.user_id = ? ORDER BY d.name",
userID,
)
if err != nil {
return nil, fmt.Errorf("list user domain names: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("list user domain names scan: %w", err)
}
names = append(names, name)
}
return names, rows.Err()
}
func (s *Store) setUserDomains(userID int64, domainIDs []int64) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("set user domains begin: %w", err)
}
if _, err := tx.Exec("DELETE FROM user_domains WHERE user_id = ?", userID); err != nil {
tx.Rollback()
return fmt.Errorf("set user domains clear: %w", err)
}
for _, did := range domainIDs {
if _, err := tx.Exec("INSERT INTO user_domains (user_id, domain_id) VALUES (?, ?)", userID, did); err != nil {
tx.Rollback()
return fmt.Errorf("set user domains insert: %w", err)
}
}
return tx.Commit()
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"errors"
"testing"
)
func TestUpdateUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash-one"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, err := st.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if err := st.UpdateUser(u.ID, "operator", "hash-two", "reports@hub.example"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
got, err := st.GetUser(u.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.DMARCReportEmail != "reports@hub.example" {
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
}
email, err := st.GlobalDMARCReportEmail()
if err != nil {
t.Fatalf("GlobalDMARCReportEmail: %v", err)
}
if email != "reports@hub.example" {
t.Fatalf("settings dmarc = %q", email)
}
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
t.Fatalf("clear dmarc email: %v", err)
}
got, err = st.GetUser(u.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.Username != "operator" || got.PasswordHash != "hash-three" {
t.Fatalf("unexpected user after update: %+v", got)
}
if got.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
func TestUpdateUserWithoutUser(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateUser(1, "operator", "hash", ""); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("UpdateUser without user = %v, want ErrUserNotFound", err)
}
exists, err := st.UserExists()
if err != nil {
t.Fatalf("UserExists: %v", err)
}
if exists {
t.Fatal("UpdateUser created a user")
}
}
func TestCreateDomainAdminUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
d, err := st.AddDomain("example.com", "s1")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if len(u.DomainIDs) != 1 || u.DomainIDs[0] != d.ID {
t.Fatalf("domain ids = %v, want [%d]", u.DomainIDs, d.ID)
}
}
func TestDeleteLastGlobalUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, err := st.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if err := st.DeleteUser(u.ID); !errors.Is(err, ErrLastGlobal) {
t.Fatalf("DeleteUser = %v, want ErrLastGlobal", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package auth implements the panel's login sessions, one-time setup flow,
// and authentication middleware.
package auth
import (
"log"
"net"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
// Config holds auth-specific panel configuration.
type Config struct {
CookieSecure bool
Hostname string
SessionIdleDays int
TrustedProxyCIDRs []*net.IPNet
}
// Module handles login, logout, setup, and session middleware.
type Module struct {
store *store.Store
cfg Config
view *view.Engine
sessions *sessionStore
setup *setupManager
loginLimiter *rateLimiter
setupLimiter *rateLimiter
trustedProxies []*net.IPNet
}
// New builds the auth module. setupTokenPath is where the current setup token
// is mirrored on disk (security.md).
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
idleDays := cfg.SessionIdleDays
if idleDays <= 0 {
idleDays = 7
}
m := &Module{
store: st,
cfg: cfg,
view: v,
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
setupLimiter: newRateLimiter(10, time.Minute),
loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m
}
// Bootstrap runs once at startup. If setup is not complete it mints and
// announces the first setup token (security.md).
func (m *Module) Bootstrap() error {
return m.setup.bootstrap()
}
// AllowLoginAttempt reports whether a login or account-password change attempt
// from r is within the rate limit (security.md).
func (m *Module) AllowLoginAttempt(r *http.Request) bool {
return m.loginLimiter.Allow(clientIP(r, m.trustedProxies))
}
// SessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
func (m *Module) SessionToken(r *http.Request) (string, bool) {
return m.sessionToken(r)
}
// RenameSession updates the username carried by a session.
func (m *Module) RenameSession(token, username string) {
m.sessions.Rename(token, username)
}
// DestroyOtherSessions invalidates every session except keep.
func (m *Module) DestroyOtherSessions(keep string) {
m.sessions.DestroyOthers(keep)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}
// clientIP extracts the peer IP for rate-limiting. By default it is the
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
// that is the address the trusted proxy itself appended, so a client can't
// forge it by sending its own XFF header.
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if len(trustedProxies) > 0 {
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
return ip.String()
}
}
}
}
return host
}
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
+256
View File
@@ -0,0 +1,256 @@
package auth
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
func newTestSessionStore(t *testing.T) *sessionStore {
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() })
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 {
t.Helper()
v, err := view.New("test")
if err != nil {
t.Fatalf("view: %v", err)
}
return v
}
func testModule(t *testing.T, cookieSecure bool) *Module {
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() })
return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "")
}
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
secure := testModule(t, true)
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
}
plain := testModule(t, false)
if got := plain.sessionCookie(); got != "selfpost_session" {
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
}
}
func TestSessionTokenRejectsDuplicates(t *testing.T) {
m := testModule(t, false)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
if token, ok := m.sessionToken(r); ok {
t.Fatalf("duplicate cookies accepted, token = %q", token)
}
}
func TestSessionTokenReadsOneCookie(t *testing.T) {
m := testModule(t, true)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
token, ok := m.sessionToken(r)
if !ok || token != "the-real-session" {
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
}
}
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
m := testModule(t, true)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
if _, ok := m.sessionToken(r); ok {
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
}
}
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
m := testModule(t, false)
token := mustCreate(t, m.sessions, "admin")
reached := false
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if reached {
t.Fatal("the handler ran even though the session cookie was shadowed")
}
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"))
}
}
func TestLogoutClearsBothCookieNames(t *testing.T) {
m := testModule(t, true)
token := mustCreate(t, m.sessions, "admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com"
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
rec := httptest.NewRecorder()
m.HandleLogout(rec, r)
if _, ok := m.sessions.Lookup(token); ok {
t.Error("the session survived sign-out")
}
set := rec.Header().Values("Set-Cookie")
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
var found bool
for _, c := range set {
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
found = true
}
}
if !found {
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
}
}
}
// 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) {
s := newTestSessionStore(t)
token := mustCreate(t, s, "admin")
s.Rename(token, "operator")
name, ok := s.Lookup(token)
if !ok {
t.Fatal("session lost after rename")
}
if name != "operator" {
t.Fatalf("session username = %q, want %q", name, "operator")
}
}
func TestSessionDestroyOthers(t *testing.T) {
s := newTestSessionStore(t)
keep := mustCreate(t, s, "admin")
other := mustCreate(t, s, "admin")
s.DestroyOthers(keep)
if _, ok := s.Lookup(keep); !ok {
t.Fatal("current session was destroyed")
}
if _, ok := s.Lookup(other); ok {
t.Fatal("other session survived")
}
}
func TestSessionLookupRejectsExpired(t *testing.T) {
s := newTestSessionStore(t)
s.idle = -time.Minute
token := mustCreate(t, s, "admin")
if _, ok := s.Lookup(token); ok {
t.Fatal("expired session was accepted")
}
}
func TestSessionTouchThrottled(t *testing.T) {
s := newTestSessionStore(t)
token := mustCreate(t, s, "admin")
if s.Touch(token) {
t.Fatal("touch renewed a session created moments ago")
}
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
t.Fatalf("renew session: %v", err)
}
if !s.Touch(token) {
t.Fatal("touch did not renew a session past the throttle window")
}
}
+243
View File
@@ -0,0 +1,243 @@
package auth
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
func (m *Module) sessionCookie() string {
if m.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
func (m *Module) sessionToken(r *http.Request) (string, bool) {
name := m.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
return "", false
}
}
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// HandleLogin serves the login form (GET) and authenticates (POST).
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := m.store.UserExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
m.view.Render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
m.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
m.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
m.view.Render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"Error": formErr,
})
}
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
user, err := m.store.GetUserByUsername(username)
if err != nil {
if !errors.Is(err, store.ErrUserNotFound) {
logf("panel: login: get user failed: %v", err)
}
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
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)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: m.sessionCookie(),
Value: token,
Path: "/",
MaxAge: m.sessions.MaxAge(),
HttpOnly: true,
Secure: m.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// HandleLogout destroys the session and clears the cookie.
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := m.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
m.sessions.Destroy(c.Value)
}
}
m.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HandleSetup serves the one-time administrator creation flow at
// /setup/<token> (security.md).
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !m.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
m.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
m.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
m.view.Render(w, status, "setup", map[string]any{
"Title": "SelfPost — Create administrator",
"Active": "setup",
"Token": token,
"Error": formErr,
})
}
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
if err := r.ParseForm(); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validate.Username(username); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validate.AdminPassword(password); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
if exists, _ := m.store.UserExists(); exists {
m.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create user failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
m.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", 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)
}
+46
View File
@@ -0,0 +1,46 @@
package auth
import (
"net/http"
)
// RequireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated
// principal is stashed in the request context for downstream handlers.
func (m *Module) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := m.sessionToken(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := m.sessions.Lookup(token)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if isSessionActivity(r) && m.sessions.Touch(token) {
m.setSessionCookie(w, token)
}
u, err := m.store.GetUserByUsername(username)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
p := principalFromUser(u)
ctx := withPrincipal(r.Context(), p)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
}
// CurrentUser returns the authenticated username from the request context.
func CurrentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok {
return v
}
return ""
}
+86
View File
@@ -0,0 +1,86 @@
package auth
import (
"context"
"net/http"
"github.com/mixeme/selfpost/internal/store"
)
type ctxKey int
const (
usernameKey ctxKey = 0
principalKey ctxKey = 1
)
// Role is a panel user's access level.
type Role = store.Role
const (
RoleGlobal = store.RoleGlobal
RoleDomainAdmin = store.RoleDomainAdmin
)
// Principal is the authenticated panel user attached to a request.
type Principal struct {
ID int64
Username string
Role Role
Domains []int64 // assigned domain IDs; empty for global (all domains)
}
// IsGlobal reports whether the principal has full panel access.
func (p Principal) IsGlobal() bool {
return p.Role == RoleGlobal
}
// CanAccessDomain reports whether the principal may access a domain id.
func (p Principal) CanAccessDomain(domainID int64) bool {
if p.IsGlobal() {
return true
}
for _, id := range p.Domains {
if id == domainID {
return true
}
}
return false
}
// CanAccessApp reports whether the principal may access an application.
func (p Principal) CanAccessApp(app store.Application) bool {
return p.CanAccessDomain(app.DomainID)
}
func principalFromUser(u store.User) Principal {
return Principal{
ID: u.ID,
Username: u.Username,
Role: u.Role,
Domains: u.DomainIDs,
}
}
func withPrincipal(ctx context.Context, p Principal) context.Context {
ctx = context.WithValue(ctx, usernameKey, p.Username)
return context.WithValue(ctx, principalKey, p)
}
// CurrentPrincipal returns the authenticated principal from the request context.
func CurrentPrincipal(ctx context.Context) (Principal, bool) {
if v, ok := ctx.Value(principalKey).(Principal); ok {
return v, true
}
return Principal{}, false
}
// PrincipalFromRequest returns the authenticated principal from an HTTP request.
func PrincipalFromRequest(r *http.Request) (Principal, bool) {
return CurrentPrincipal(r.Context())
}
// RequestWithPrincipal attaches a principal for middleware-equivalent tests.
func RequestWithPrincipal(r *http.Request, p Principal) *http.Request {
return r.WithContext(withPrincipal(r.Context(), p))
}
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"sync"
"time"
)
const defaultMaxBuckets = 4096
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct {
max int
window time.Duration
maxBuckets int
mu sync.Mutex
buckets map[string]*rlBucket
}
type rlBucket struct {
count int
windowEnds time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: max,
window: window,
maxBuckets: defaultMaxBuckets,
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 {
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
b := r.buckets[key]
if b == nil || now.After(b.windowEnds) {
r.makeRoom(now)
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
r.sweep(now)
return true
}
if b.count >= r.max {
return false
}
b.count++
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) {
for k, b := range r.buckets {
if now.After(b.windowEnds) {
delete(r.buckets, k)
}
}
}
+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))
}
}
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/sha256"
@@ -22,9 +22,6 @@ const renewThreshold = time.Hour
// cookie.
type sessionStore struct {
store *store.Store
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
// no absolute cap: an administrator who keeps coming back stays signed in
// indefinitely, deliberately.
idle time.Duration
}
@@ -44,21 +41,22 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
// Create issues a new session for username and returns its token.
func (s *sessionStore) Create(username string) string {
// Create issues a new session for username and returns its token. It fails
// 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)
now := time.Now()
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
logf("panel: session: create failed: %v", err)
return "", err
}
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
// in the table forever. Piggybacking on Create (the one write every login
// already pays for) avoids a dedicated background sweep for what is, on a
// single-admin panel, a handful of rows at most.
// 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 {
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
@@ -86,10 +84,7 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
}
// Touch extends a session's sliding expiry if it has been at least
// renewThreshold since the last extension, and reports whether it did so
// the caller uses that to decide whether the response needs a fresh
// Set-Cookie. It assumes the caller has just confirmed the session is valid
// (e.g. via Lookup); it does nothing for a token that no longer exists.
// renewThreshold since the last extension, and reports whether it did so.
func (s *sessionStore) Touch(token string) bool {
hash := hashToken(token)
row, found, err := s.store.LookupSession(hash)
@@ -100,8 +95,6 @@ func (s *sessionStore) Touch(token string) bool {
if !found {
return false
}
// expiresAt = lastRenewal + idle, so this recovers when the session was
// last extended without a separate column.
lastRenewal := row.ExpiresAt.Add(-s.idle)
now := time.Now()
if now.Sub(lastRenewal) < renewThreshold {
@@ -114,19 +107,14 @@ func (s *sessionStore) Touch(token string) bool {
return true
}
// Rename updates the username carried by a session, keeping its expiry. It is
// used when the administrator renames their own account so the current
// session keeps working under the new name.
// Rename updates the username carried by a session, keeping its expiry.
func (s *sessionStore) Rename(token, username string) {
if err := s.store.RenameSession(hashToken(token), username); err != nil {
logf("panel: session: rename failed: %v", err)
}
}
// DestroyOthers invalidates every session except keep. It is called when the
// administrator changes their password: a stolen cookie issued under the old
// password must stop working, while the admin performing the change stays
// signed in.
// DestroyOthers invalidates every session except keep.
func (s *sessionStore) DestroyOthers(keep string) {
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
logf("panel: session: destroy others failed: %v", err)
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/subtle"
@@ -16,8 +16,9 @@ const setupTokenTTL = 10 * time.Minute
// setupManager owns the one-time administrator setup token. The token itself is
// 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
// store, so once that exists the token is gone for good (security.md).
// persistent "setup complete" fact is the presence of any row in the store's
// users table (`store.UserExists`), so once the first global administrator is
// created the token is gone for good (security.md).
type setupManager struct {
store *store.Store
hostname string
@@ -32,10 +33,8 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
}
// bootstrap runs once at startup. If setup is already complete it clears any
// stale token file; otherwise it mints and announces the first token.
func (m *setupManager) bootstrap() error {
done, err := m.store.AdminExists()
done, err := m.store.UserExists()
if err != nil {
return err
}
@@ -49,13 +48,10 @@ func (m *setupManager) bootstrap() error {
return nil
}
// activeToken returns the current valid setup token, regenerating and
// re-announcing it if none exists or it has expired. It returns ("", false)
// once setup is complete — callers must treat that as "route gone" (404).
func (m *setupManager) activeToken() (string, bool) {
done, err := m.store.AdminExists()
done, err := m.store.UserExists()
if err != nil {
logf("panel: setup: admin check failed: %v", err)
logf("panel: setup: user check failed: %v", err)
return "", false
}
if done {
@@ -69,10 +65,6 @@ func (m *setupManager) activeToken() (string, bool) {
return m.token, true
}
// validate reports whether provided matches the active token, using a
// constant-time comparison to avoid leaking a correct prefix via timing
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (security.md).
func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken()
if !ok {
@@ -81,8 +73,6 @@ func (m *setupManager) validate(provided string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
}
// complete marks setup as finished: the admin row now exists, so drop the
// in-memory token and remove the on-disk copy.
func (m *setupManager) complete() {
m.mu.Lock()
m.token = ""
@@ -91,16 +81,12 @@ func (m *setupManager) complete() {
m.clearTokenFile()
}
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() {
m.token = randomToken(16) // 128 bits of entropy (security.md)
m.token = randomToken(16)
m.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token)
}
// announce prints the setup link to the container log and writes it to the
// token file so it can be read either way (security.md).
func (m *setupManager) announce(token string) {
url := m.setupURL(token)
logf("panel: ==================================================================")
@@ -112,7 +98,6 @@ func (m *setupManager) announce(token string) {
if m.tokenPath == "" {
return
}
// 0600: the token is a bearer secret for creating the admin.
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
}
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/rand"
+55
View File
@@ -0,0 +1,55 @@
package handlers
import (
"net/http"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) {
return auth.PrincipalFromRequest(r)
}
func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
p, ok := h.principal(r)
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return auth.Principal{}, false
}
return p, true
}
func (h *Handlers) pageBase(r *http.Request) map[string]any {
p, _ := h.principal(r)
return map[string]any{
"User": auth.CurrentUser(r),
"IsGlobal": p.IsGlobal(),
}
}
func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
if p.IsGlobal() {
return h.store.ListDomains()
}
return h.store.ListDomainsForUser(p.ID)
}
func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
m[d.Name] = true
}
return m
}
func domainIDSet(p auth.Principal) map[int64]bool {
if p.IsGlobal() {
return nil
}
m := make(map[int64]bool, len(p.Domains))
for _, id := range p.Domains {
m[id] = true
}
return m
}
+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]
}
+70
View File
@@ -0,0 +1,70 @@
// Package handlers implements the panel's authenticated HTTP handlers.
package handlers
import (
"log"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
// Config holds handler-specific panel configuration.
type Config struct {
Hostname string
SubmissionEnabled bool
MailLogPath string
DataDir string
DBPath string
Version string
TLSCertFile string
OpenDKIMSocket string
JournalSocket string
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
}
// Handlers holds dependencies for authenticated panel routes.
type Handlers struct {
store *store.Store
domains *domain.Service
apps *app.Service
cfg Config
view *view.Engine
dns *dnscheck.Checker
machine *health.MachineSampler
auth *auth.Module
}
// New builds authenticated panel handlers.
func New(
st *store.Store,
domains *domain.Service,
apps *app.Service,
cfg Config,
v *view.Engine,
dns *dnscheck.Checker,
machine *health.MachineSampler,
a *auth.Module,
) *Handlers {
return &Handlers{
store: st,
domains: domains,
apps: apps,
cfg: cfg,
view: v,
dns: dns,
machine: machine,
auth: a,
}
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}
@@ -1,4 +1,4 @@
package web
package handlers
import (
"errors"
@@ -10,6 +10,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
)
// newCred carries a freshly generated login/password to the template so it can
@@ -48,28 +49,28 @@ type appRateLimitView struct {
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (product.md).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
}
// renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state.
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := s.domains.DKIMRecord(d)
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
apps, err := s.apps.List(d.ID)
apps, err := h.apps.List(d.ID)
if err != nil {
logf("panel: domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -77,7 +78,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
}
appViews := make([]appRateLimitView, 0, len(apps))
for _, a := range apps {
rl, ok, err := s.apps.RateLimit(a.ID)
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -92,7 +93,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
})
}
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -102,45 +103,74 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
dns, srv := s.domainDNS(d, record, false)
profileEmail, err := h.store.GlobalDMARCReportEmail()
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := h.domainDNS(d, record, profileEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
dmarcMode := "inherit"
dmarcCustom := ""
if d.DMARCRua.Valid {
if d.DMARCRua.String == "" {
dmarcMode = "none"
} else {
dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String
}
}
dmarcSource := "policy"
switch {
case dmarcMode == "custom":
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case profileEmail != "":
dmarcSource = "settings"
}
s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Active": "domains",
"Domain": d,
"Record": record,
"DNS": dns,
// SPF and DMARC are the operator's to write — SelfPost cannot generate
// them the way it generates the DKIM record — so the page shows what
// this server expects rather than leaving it to the documentation. The
// same builders phrase the suggestions in the check messages, so the
// page and the checks below it never recommend different records.
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
"DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(d.Name),
// Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment.
"Hostname": s.cfg.Hostname,
"SubmissionEnabled": s.cfg.SubmissionEnabled,
"Apps": appViews,
"Error": view.FormErr,
"FormLogin": view.FormLogin,
"FormMode": view.FormMode,
"FormAddrs": view.FormAddrs,
"NewCred": view.NewCred,
"Flash": detailFlash(r),
"Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"ExportErr": view.ExportErr,
"MinPwLen": minSecretFilePasswordLen,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
})
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "domains"
data["Domain"] = d
data["Record"] = record
data["DNS"] = dns
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
data["DMARCSource"] = dmarcSource
data["ProfileDMARCEmail"] = profileEmail
data["ResolvedDMARCEmail"] = reportEmail
data["DMARCRuaMode"] = dmarcMode
data["DMARCRuaCustom"] = dmarcCustom
data["ReportAuthName"] = reportAuthName
data["ReportAuthValue"] = reportAuthValue
data["NeedsReportAuth"] = needsReportAuth
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
data["Hostname"] = h.cfg.Hostname
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
data["Apps"] = appViews
data["Error"] = view.FormErr
data["FormLogin"] = view.FormLogin
data["FormMode"] = view.FormMode
data["FormAddrs"] = view.FormAddrs
data["NewCred"] = view.NewCred
data["Flash"] = detailFlash(r)
data["Wildcard"] = store.AddressModeWildcard
data["List"] = store.AddressModeList
data["RateLimitErr"] = view.RateLimitErr
data["ExportErr"] = view.ExportErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
data["DomainHasRL"] = domainRLok && domainRL.Active()
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
data["DomainRLMaxNum"] = domainRL.MaxMessages
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "domain_detail", data)
}
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
@@ -149,31 +179,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
}, force), srv
}
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// returns to its page, which then renders the fresh result.
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
record, err := s.domains.DKIMRecord(d)
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.domainDNS(d, record, true)
profileEmail, err := h.store.GlobalDMARCReportEmail()
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, profileEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
@@ -205,6 +242,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("dmarc") != "":
return "DMARC report settings updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
case r.URL.Query().Get("rechecked") != "":
@@ -214,17 +253,17 @@ func detailFlash(r *http.Request) string {
}
}
// handleAddApplication creates an application on a domain and renders the page
// HandleAddApplication creates an application on a domain and renders the page
// back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
return
}
@@ -238,25 +277,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
FormAddrs: r.PostFormValue("addresses"),
}
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
if err != nil {
repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict
}
s.renderDomainDetail(w, r, status, d, repopulate)
h.renderDomainDetail(w, r, status, d, repopulate)
return
}
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleUpdateAppMode switches an application's address mode / list (product.md).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
// HandleUpdateAppMode switches an application's address mode / list (product.md).
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
@@ -267,13 +306,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := s.domains.Get(a.DomainID)
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := h.domains.Get(a.DomainID)
if derr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard,
})
@@ -282,38 +321,38 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// handleRegenPassword issues a new password for an application and shows it once
// HandleRegenPassword issues a new password for an application and shows it once
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := s.domains.Get(a.DomainID)
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
password, err := s.apps.RegeneratePassword(a.ID)
password, err := h.apps.RegeneratePassword(a.ID)
if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleDeleteApplication removes an application and returns to its domain page
// HandleDeleteApplication removes an application and returns to its domain page
// (product.md).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
if err := s.apps.Delete(a.ID); err != nil {
if err := h.apps.Delete(a.ID); err != nil {
logf("panel: delete application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -323,13 +362,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request)
// lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application.
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.Application{}, false
}
a, err := s.apps.Get(id)
a, err := h.apps.Get(id)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
http.NotFound(w, r)
@@ -339,6 +378,11 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Application{}, false
}
p, ok := h.principal(r)
if !ok || !p.CanAccessApp(a) {
http.NotFound(w, r)
return store.Application{}, false
}
return a, true
}
@@ -1,4 +1,4 @@
package web
package handlers
import (
"bytes"
@@ -13,6 +13,7 @@ import (
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -22,35 +23,37 @@ import (
// covers both forms.
const maxImportBytes = 1 << 20 // 1 MiB
// handleBackupPage renders the backup/migration screen: the full-server backup
// HandleBackupPage renders the backup/migration screen: the full-server backup
// and the domain import are separate actions with different risk, so each gets
// its own card here rather than sharing a block on the domain list.
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
s.renderBackupPage(w, r, http.StatusOK, "")
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.renderBackupPage(w, r, http.StatusOK, "")
}
// renderBackupPage draws the page; importErr surfaces a failed domain import
// (architecture.md § Persistence) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.renderBackupPageWith(w, r, status, importErr, "")
func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
h.renderBackupPageWith(w, r, status, importErr, "")
}
// renderBackupPageWith is renderBackupPage with the second of the page's two
// error slots: backupErr belongs to the full-backup card (a rejected encryption
// password), importErr to the import card, so neither message appears under the
// wrong form.
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
s.render(w, status, "backup", map[string]any{
"Title": "SelfPost — backup",
"User": currentUser(r),
"Active": "backup",
"ImportErr": importErr,
"BackupErr": backupErr,
"MinPwLen": minSecretFilePasswordLen,
})
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
data := h.pageBase(r)
data["Title"] = "SelfPost — backup & migration"
data["Active"] = "backup"
data["ImportErr"] = importErr
data["BackupErr"] = backupErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
h.view.Render(w, status, "backup", data)
}
// handleBackup streams a full-server backup as a download (architecture.md §
// HandleBackup streams a full-server backup as a download (architecture.md §
// Persistence). It is an authenticated admin action (this handler sits behind
// the auth middleware). The archive carries DKIM private keys, the admin
// password hash and SASL credentials, so it is served with no-store and as an
@@ -58,10 +61,13 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
// way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return
}
@@ -99,9 +105,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
}
if err := backup.Create(sink, backup.Params{
DataDir: s.cfg.DataDir,
DBPath: s.cfg.DBPath,
Version: s.cfg.Version,
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
}); err != nil {
logf("panel: full backup failed: %v", err)
return
@@ -113,26 +119,26 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
}
}
// handleExportDomain streams a single-domain export as a secret download
// HandleExportDomain streams a single-domain export as a secret download
// (architecture.md § Persistence). Like the full backup it is POST-only (state
// is not changed, but the response contains the domain's DKIM private key and
// application passwords, so it must not be prefetchable or cached). Like the
// full backup it can be encrypted with a password, in which case the download
// is a .spde envelope instead of plain JSON.
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
ExportErr: pwErr,
})
return
}
exp, err := s.domains.Export(d.ID)
exp, err := h.domains.Export(d.ID)
if err != nil {
logf("panel: export domain %d: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError)
@@ -174,22 +180,25 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
// handleImportDomain accepts an uploaded domain-export file and re-creates the
// HandleImportDomain accepts an uploaded domain-export file and re-creates the
// domain on this instance (architecture.md § Persistence). The domain name is
// normalised and validated here (security.md); the domain service validates
// the selector, each login and address, and the DKIM key before writing
// anything. On success it redirects to the new domain's page; on failure it
// re-renders the backup page, where the import form lives, with a friendly
// message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
return
}
file, _, err := r.FormFile("file")
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
return
}
defer file.Close()
@@ -201,7 +210,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
head := make([]byte, secretfile.MagicLen)
n, err := io.ReadFull(file, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
return
}
source := io.MultiReader(bytes.NewReader(head[:n]), file)
@@ -209,16 +218,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
if secretfile.HasMagic(head[:n]) {
if password == "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
return
}
env, err := secretfile.NewReader(source, password)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
if env.Type() != secretfile.TypeDomainExport {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
return
}
// Read the whole plaintext first: authentication of the last chunk is
@@ -226,12 +235,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
// accept a truncated document before ever reaching it.
plain, err := io.ReadAll(env)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
source = bytes.NewReader(plain)
} else if password != "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
return
}
@@ -239,23 +248,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(source)
dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
return
}
// Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (security.md).
exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
exp.Domain = validate.NormalizeDomain(exp.Domain)
if err := validate.Domain(exp.Domain); err != nil {
h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
return
}
d, err := s.domains.Import(exp)
d, err := h.domains.Import(exp)
if err != nil {
logf("panel: import domain %q: %v", exp.Domain, err)
status, msg := importErrorMessage(err)
s.renderBackupPage(w, r, status, msg)
h.renderBackupPage(w, r, status, msg)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
@@ -276,8 +285,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) {
return "", ""
}
password = r.PostFormValue("password")
if len([]rune(password)) < minSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
if len([]rune(password)) < validate.MinSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen)
}
if password != r.PostFormValue("password_confirm") {
return "", "The two passwords do not match."
@@ -1,4 +1,4 @@
package web
package handlers
import (
"errors"
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
// postForm builds the kind of request the backup and export forms submit.
@@ -24,8 +25,8 @@ func postForm(values url.Values) *http.Request {
// the archive is sealed — and leaving the box unticked has to keep producing
// the plain archive earlier versions produced.
func TestSecretFilePassword(t *testing.T) {
long := strings.Repeat("x", minSecretFilePasswordLen)
short := strings.Repeat("x", minSecretFilePasswordLen-1)
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
tests := []struct {
name string
@@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) {
// forgets to include the partial (or the data it needs) loses the option
// silently, since the plain download still works.
func TestBackupPageOffersEncryption(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
@@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
"data-import-password-fields",
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
"The two passwords do not match.",
} {
if !strings.Contains(body, want) {
+57
View File
@@ -0,0 +1,57 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/web/validate"
)
// HandleDomainDMARC saves per-domain DMARC rua= settings.
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
return
}
var rua sql.NullString
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
case "inherit":
rua = sql.NullString{}
case "none":
rua = sql.NullString{Valid: true, String: ""}
case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
return
}
if email == "" {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return
}
rua = sql.NullString{Valid: true, String: email}
default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return
}
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return
}
h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
+208
View File
@@ -0,0 +1,208 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
// domainRow is one line of the domain list: the stored domain plus the rolled-up
// verdict of its published DNS records, so the operator sees which domains still
// need a record published without opening each one.
type domainRow struct {
store.Domain
DNS health.Status
}
// HandleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (product.md).
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
h.renderDashboard(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — domains"
data["Active"] = "domains"
data["Domains"] = h.domainRows(domains)
data["Error"] = formErr
data["FormName"] = formName
data["Flash"] = dashboardFlash(r)
h.view.Render(w, status, "dashboard", data)
}
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
profileEmail = email
}
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
wg.Add(1)
go func() {
defer wg.Done()
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, profileEmail, false)
rows[i].DNS = dns.Overall
}()
}
wg.Wait()
return rows
}
func dashboardFlash(r *http.Request) string {
if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
}
return ""
}
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
h.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
"IsGlobal": true,
})
}
// HandleDeleteDomain performs the deletion and returns to the domain list.
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
id, ok := parseDomainID(w, r)
if !ok {
return
}
if d, err := h.domains.Get(id); err == nil {
defer h.dns.Forget(d.Name)
}
if err := h.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
}
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (architecture.md § Panel HTTP surface).
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
if err := h.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
if err := h.apps.Resync(); err != nil {
logf("panel: manual reload (postfix): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := h.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
p, ok := h.principal(r)
if !ok || !p.CanAccessDomain(d.ID) {
http.NotFound(w, r)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
@@ -1,10 +1,11 @@
package web
package handlers
import (
"errors"
"io/fs"
"net/http"
"net/url"
"sort"
"strconv"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
@@ -24,38 +26,40 @@ const (
deliveryLogLines = 200
)
// handleDeliveries renders the Deliveries page over the send log: server-side
// HandleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (architecture.md §
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
// refreshes never diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := h.sendLogData(r)
if err != nil {
logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r)
for k, v := range h.pageBase(r) {
data[k] = v
}
data["Active"] = "deliveries"
s.render(w, http.StatusOK, "deliveries", data)
h.view.Render(w, http.StatusOK, "deliveries", data)
}
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := h.sendLogData(r)
if err != nil {
logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
}
// handleDelivery renders one send-log row in full. The log itself carries only
// HandleDelivery renders one send-log row in full. The log itself carries only
// what identifies a message at a glance — when, who to and from, what about,
// how it ended — and every remaining field (domain, application, queue id, when
// the status was last reported) lives here, one page per row, so widening the
@@ -67,13 +71,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
// its history side by side, and the mail.log lines for its queue id under both.
// The queue id used to be printed here as something to go and search the system
// log for by hand; the search is done for the operator instead.
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
row, err := s.store.GetSendLog(id)
row, err := h.store.GetSendLog(id)
if err != nil {
// A row pruned on the retention window is gone, not broken.
if errors.Is(err, store.ErrSendLogNotFound) {
@@ -84,12 +88,29 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !p.IsGlobal() {
allowed, err := h.assignedDomains(p)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !domainNameSet(allowed)[row.Domain] {
http.NotFound(w, r)
return
}
}
row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := s.deliveryLog(row)
s.render(w, http.StatusOK, "delivery", map[string]any{
logRows, logNote := h.deliveryLog(row)
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "deliveries",
"IsGlobal": p.IsGlobal(),
"Row": row,
// The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel.
@@ -124,6 +145,17 @@ func deliveryLevel(status string) string {
}
}
// sendLogRow is a row of the send log as the table draws it: the stored row
// plus the badge level its status maps onto. The level is carried rather than
// derived in the template because deliveryLevel is the one place that decides
// what a status means — the delivery page already reads it, and a second
// mapping written in the template or the stylesheet would be free to drift
// from it.
type sendLogRow struct {
store.SendLogRow
Level string // ok / warn / error / unknown, as deliveryLevel returns
}
// deliveryEvent is one step of a message's history, as the timeline on the
// delivery page draws it. At is zero for the step that has not happened yet —
// the delivery report a queued message is still waiting for.
@@ -223,11 +255,11 @@ type deliveryLogRow struct {
// have aged out of the log — so none of them is an error on the page. Only a
// log that cannot be read at all is reported as a fault, and that one is
// logged for the operator as well.
func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
if row.QueueID == "" {
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
}
lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines)
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
logf("panel: delivery log %s: %v", row.QueueID, err)
return nil, "Could not read the mail log."
@@ -267,39 +299,81 @@ func deliveriesBackURL(r *http.Request) string {
// sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination).
func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
//
// 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) {
p, ok := h.principal(r)
if !ok {
return nil, errors.New("no principal")
}
q := r.URL.Query()
assigned, err := h.assignedDomains(p)
if err != nil {
return nil, err
}
allowedNames := domainNameSet(assigned)
domainNames := make([]string, 0, len(assigned))
for _, d := range assigned {
domainNames = append(domainNames, d.Name)
}
loginSet := make(map[string]bool)
for _, d := range assigned {
apps, err := h.store.ListApplicationsByDomain(d.ID)
if err != nil {
return nil, err
}
for _, a := range apps {
loginSet[a.Login] = true
}
}
logins := make([]string, 0, len(loginSet))
for login := range loginSet {
logins = append(logins, login)
}
sort.Strings(logins)
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 = ""
}
}
page := parsePage(q.Get("p"))
total, err := s.store.CountSendLog(filter)
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
// Decode on the way out as well as on the way in: rows the journal-milter
// wrote before it decoded subjects itself still hold the raw header, and
// they are the ones an operator is most likely to be looking at.
view := make([]sendLogRow, len(rows))
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
}
domains, err := s.store.ListDomains()
if err != nil {
return nil, err
}
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
logins, err := s.store.ListApplicationLogins()
if err != nil {
return nil, err
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
}
lastPage := 1
@@ -307,7 +381,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
}
return map[string]any{
"Rows": rows,
"Rows": view,
"FilterDomains": domainNames,
"FilterApps": logins,
"FilterDomain": filter.Domain,
@@ -331,23 +405,30 @@ func parsePage(v string) int {
return n
}
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
out, errText := readQueue()
s.render(w, http.StatusOK, "mail_queue", map[string]any{
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
})
}
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
out, errText := readQueue()
s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out,
"Error": errText,
})
@@ -365,30 +446,37 @@ func readQueue() (string, string) {
return out, ""
}
// handleSystemLog renders the System log page over mail.log (architecture.md §
// HandleSystemLog renders the System log page over mail.log (architecture.md §
// Panel HTTP surface).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "system_log", map[string]any{
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail()
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "system_log",
"IsGlobal": true,
"Lines": lines,
"Error": errText,
})
}
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail()
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines,
"Error": errText,
})
}
func (s *Server) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
func (h *Handlers) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Rotation renamed the file away; Postfix recreates it on reload
@@ -396,7 +484,7 @@ func (s *Server) readLogTail() ([]string, string) {
// than a failure worth alarming the operator about.
return nil, ""
}
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
return nil, "Could not read the mail log."
}
return lines, ""
@@ -1,4 +1,4 @@
package web
package handlers
import (
"net/http"
@@ -10,15 +10,16 @@ import (
"testing"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// After log rotation renames mail.log away, Postfix takes about a second to
// recreate it on reload (spec B.2); a missing file in that window is a normal,
// transient gap, not an operator-facing failure.
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
lines, errText := s.readLogTail()
lines, errText := h.readLogTail()
if lines != nil {
t.Errorf("lines = %v, want nil", lines)
}
@@ -31,9 +32,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
// when, from, to, subject and status, and links each row to the page carrying
// the rest. A column added back here is one the table has no width for.
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDeliveries, "/deliveries")
out := getBody(t, h.HandleDeliveries, "/deliveries")
for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru",
@@ -56,11 +57,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
// decoded them, and those rows are still in the send log. Decoding on the way
// out is what keeps them readable, so the encoding must not survive to the page.
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
s, _ := serverWithDelivery(t)
h, _ := serverWithDelivery(t)
for name, out := range map[string]string{
"log": getBody(t, s.handleDeliveries, "/deliveries"),
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
} {
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
@@ -74,9 +75,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
// Everything the log dropped has to be somewhere, and that somewhere is the
// per-row page — including for a row still holding an encoded subject.
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
for _, want := range []string{
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
"noreply@bs.example.ru", "public@example.ru", "sent",
@@ -95,9 +96,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
// journal holds, stated as the steps they stand for, so a row is readable as
// what happened to the message rather than as a list of fields.
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -121,19 +122,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
// for is drawn as one that has not happened rather than dated with the moment
// the row was written.
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
s, _ := serverWithDelivery(t)
if err := s.store.InsertQueued(store.SendLogEntry{
h, _ := serverWithDelivery(t)
if err := h.store.InsertQueued(store.SendLogEntry{
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
}); err != nil {
t.Fatalf("insert: %v", err)
}
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
if !strings.Contains(out, want) {
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
@@ -146,14 +147,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
// lines — as a table of when and what, so the seconds between the connection
// and the reply line up down one edge.
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t,
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t,
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"<th>Time</th>", "<th>Message</th>",
// The stamp is split off into its own cell, without the microseconds
@@ -174,10 +175,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
// A line whose head is not a timestamp still has to show in full; the format is
// the log's, not ours, and a line we cannot split is a line we must not drop.
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=&lt;public@example.ru&gt;, status=sent (250 OK)") {
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
}
@@ -186,10 +187,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
// Rows outlive mail.log, and a message the milter refused never reached the
// queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
@@ -201,22 +202,190 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) {
s, _ := serverWithDelivery(t)
h, _ := serverWithDelivery(t)
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
s.handleDelivery(rec, req)
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
}
}
}
// 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
// the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -237,25 +406,28 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
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 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
return &Server{store: st, tmpl: tmpl, 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
// 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.
// getBody runs one handler over a GET as the global administrator.
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()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
+188
View File
@@ -0,0 +1,188 @@
package handlers
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
)
const defaultRateLimitWindowSeconds = 3600
type rateLimitInput struct {
clear bool
ips []string
maxMessages int
windowSeconds int
}
func (h *Handlers) l1Messages() int {
if h.cfg.RateLimitMessagesPerIP > 0 {
return h.cfg.RateLimitMessagesPerIP
}
return 100
}
func (h *Handlers) l1Window() int {
if h.cfg.RateLimitWindowSeconds > 0 {
return h.cfg.RateLimitWindowSeconds
}
return defaultRateLimitWindowSeconds
}
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
}
if maxMessages > l1Max {
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil {
return rateLimitInput{}, err
}
if len(ips) == 0 {
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
}
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
}
if maxMessages > l1Max {
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
}
if domainActive && maxMessages <= domainMax {
return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax)
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
})
var out []string
seen := make(map[string]bool)
for _, f := range fields {
ip := net.ParseIP(f)
if ip == nil {
return nil, fmt.Errorf("%q is not a valid IP address", f)
}
c := ip.String()
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return def, nil
}
return strconv.Atoi(raw)
}
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
in, err := parseDomainRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
return
}
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainRL, domainOK, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainActive := domainOK && domainRL.Active()
in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive)
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) applyRateLimit(
in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) error,
id int64,
) error {
if in.clear {
return clear(id)
}
return save(id, in.ips, in.maxMessages, in.windowSeconds)
}
@@ -0,0 +1,93 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestParseDomainRateLimitForm(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseDomainRateLimitForm(form(url.Values{
"max_messages": {"50"},
"window_seconds": {"3600"},
}), 100)
if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 {
t.Fatalf("valid domain = %+v err=%v", in, err)
}
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
if err != nil || !in.clear {
t.Fatalf("empty max should clear: %+v err=%v", in, err)
}
_, err = parseDomainRateLimitForm(form(url.Values{
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100)
if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err)
}
}
func TestParseAppRateLimitForm(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 {
t.Fatalf("valid app override = %+v err=%v", in, err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err == nil || !strings.Contains(err.Error(), "trusted client IP") {
t.Fatalf("missing IPs want error, got %v", err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"40"},
"window_seconds": {"3600"},
}), 100, 40, true)
if err == nil || !strings.Contains(err.Error(), "greater than the domain") {
t.Fatalf("app <= domain want error, got %v", err)
}
_, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100, 0, false)
if err == nil || !strings.Contains(err.Error(), "level-1") {
t.Fatalf("over L1 want error, got %v", err)
}
// No domain limit: any app ceiling ≤ L1 is fine.
in, err = parseAppRateLimitForm(form(url.Values{
"allowed_ips": {"203.0.113.10"},
"max_messages": {"50"},
"window_seconds": {"3600"},
}), 100, 0, false)
if err != nil || in.maxMessages != 50 {
t.Fatalf("app without domain = %+v err=%v", in, err)
}
}
+221
View File
@@ -0,0 +1,221 @@
package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
// HandleSettings serves the signed-in user's panel settings.
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
case http.MethodPost:
h.submitSettings(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
var reportAuth dnscheck.Result
if showDMARC && formDMARCEmail != "" {
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
reportAuth = h.dns.ReportAuth(ctx, hub)
cancel()
}
}
data := h.pageBase(r)
data["Title"] = "SelfPost — settings"
data["Active"] = "settings"
data["FormUsername"] = formUsername
data["FormDMARCEmail"] = formDMARCEmail
data["ShowDMARC"] = showDMARC
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
data["ReportAuthDNS"] = reportAuth
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["Error"] = formErr
data["Flash"] = settingsFlash(r)
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data)
}
func settingsFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
case "email":
return "DMARC report address updated."
case "username-email":
return "Username and DMARC report address updated."
case "password-email":
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
case "all":
return "Settings updated. Any other signed-in sessions were signed out."
default:
return ""
}
}
func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) {
p, _ := h.principal(r)
h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
if err := r.ParseForm(); err != nil {
p, _ := h.principal(r)
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
if !p.IsGlobal() {
dmarcEmail = user.DMARCReportEmail
}
if username == "" {
username = user.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
return
}
renaming := username != user.Username
if renaming {
if err := validate.Username(username); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return
}
}
if p.IsGlobal() {
if err := validate.Email(dmarcEmail); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
return
}
}
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return
}
}
if !renaming && !repassword && !emailChanging {
h.renderSettings(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
return
}
hash := user.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: settings: hashing password failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
return
}
hash = string(newHash)
}
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
logf("panel: settings: update user failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrUserNotFound) {
msg = "There is no user account to update."
}
if errors.Is(err, store.ErrUserExists) {
msg = "That username is already in use."
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
return
}
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
return
}
if token, ok := h.auth.SessionToken(r); ok {
if renaming {
h.auth.RenameSession(token, username)
}
if repassword {
h.auth.DestroyOtherSessions(token)
}
}
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)
}
func updatedFlag(renamed, repassword, emailChanged bool) string {
switch {
case renamed && repassword && emailChanged:
return "all"
case renamed && emailChanged:
return "username-email"
case repassword && emailChanged:
return "password-email"
case renamed && repassword:
return "both"
case renamed:
return "username"
case repassword:
return "password"
default:
return "email"
}
}
+120
View File
@@ -0,0 +1,120 @@
package handlers
import (
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/web/auth"
)
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
data := h.statusBody()
data["Title"] = "SelfPost — status"
data["User"] = auth.CurrentUser(r)
data["Active"] = "status"
data["IsGlobal"] = true
data["Flash"] = statusFlash(r)
h.view.Render(w, http.StatusOK, "status", data)
}
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
}
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.dns.Server(h.cfg.Hostname, true)
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
}
func (h *Handlers) statusBody() map[string]any {
procs, procErr := health.Processes()
procStatus := health.StatusUnknown
if procErr != nil {
logf("panel: status: supervisorctl: %v", procErr)
} else {
for _, p := range procs {
procStatus = health.Worst(procStatus, p.Status)
}
}
queueText, queueErr := readQueue()
queueStatus := health.StatusOK
if queueErr != "" {
queueStatus = health.StatusWarn
}
cert := health.CheckCertificate(h.cfg.TLSCertFile)
sockets := []health.Socket{
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
}
socketStatus := health.StatusUnknown
for _, sock := range sockets {
socketStatus = health.Worst(socketStatus, sock.Status)
}
machine := h.machine.Sample()
srv := h.dns.Server(h.cfg.Hostname, false)
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{
"Processes": procs,
"ProcessError": procErr != nil,
"ProcessStatus": procStatus,
"QueueSummary": queueSummary(queueText),
"QueueError": queueErr,
"QueueStatus": queueStatus,
"Machine": machine,
"Cert": cert,
"Sockets": sockets,
"SocketStatus": socketStatus,
"Hostname": h.cfg.Hostname,
"PTR": srv.PTR,
"OverallStatus": overall,
"OverallHeading": overallHeading(overall),
}
}
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
}
}
return ""
}
func overallHeading(worst health.Status) string {
switch worst {
case health.StatusError:
return "A component needs attention — see the details below."
case health.StatusWarn:
return "Running, with warnings below."
case health.StatusOK:
return "All components are running normally."
default:
return "Some checks could not be performed."
}
}
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
+390
View File
@@ -0,0 +1,390 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
type userFormView struct {
FormErr string
FormUsername string
FormRole string
FormDomains map[int64]bool
FormPassword string
}
// HandleUsers lists panel users (global only).
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
rows, err := h.store.ListUserRows()
if err != nil {
logf("panel: list users: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — users"
data["Active"] = "users"
data["Users"] = rows
data["Flash"] = usersFlash(r)
h.view.Render(w, http.StatusOK, "users", data)
}
func usersFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "created":
return "User created."
case "updated":
return "User updated."
case "deleted":
return "User deleted."
default:
return ""
}
}
// HandleUserNew creates a panel user (global only).
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
case http.MethodPost:
h.submitUserCreate(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleUserEdit edits or deletes a panel user (global only).
func (h *Handlers) HandleUserEdit(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
}
switch r.Method {
case http.MethodGet:
selected := make(map[int64]bool, len(u.DomainIDs))
for _, id := range u.DomainIDs {
selected[id] = true
}
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
FormUsername: u.Username,
FormRole: string(u.Role),
FormDomains: selected,
})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
h.submitUserUpdate(w, r, u)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: user form: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
if userID != 0 {
data["Title"] = "SelfPost — edit user"
} else {
data["Title"] = "SelfPost — create user"
}
data["Active"] = "users"
data["UserID"] = userID
data["Domains"] = domains
data["Error"] = view.FormErr
data["FormUsername"] = view.FormUsername
data["FormRole"] = view.FormRole
data["GlobalRole"] = store.RoleGlobal
data["FormDomains"] = view.FormDomains
data["FormPassword"] = view.FormPassword
data["IsEdit"] = userID != 0
data["LastGlobalLocked"] = lastGlobalLocked(h, userID, view.FormRole)
h.view.Render(w, status, "user_form", data)
}
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
if userID == 0 || formRole != string(store.RoleGlobal) {
return false
}
n, err := h.store.CountGlobalUsers()
return err == nil && n <= 1
}
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
domainIDs := parseDomainIDs(r)
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: create user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
return
}
if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
logf("panel: create user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
domainIDs := parseDomainIDs(r)
selected := domainIDSetFromForm(r)
if username == "" {
username = u.Username
}
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
return
}
}
if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
return
}
}
hash := u.PasswordHash
if password != "" {
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: update user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
logf("panel: update user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != u.Role {
if err := h.store.SetUserRole(u.ID, role); err != nil {
logf("panel: set user role: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleGlobal {
if err := h.store.ClearUserDomains(u.ID); err != nil {
logf("panel: clear user domains: %v", err)
}
}
}
if role == store.RoleDomainAdmin {
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
logf("panel: set user domains: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
}
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) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if u.ID == p.ID {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
if err := h.store.DeleteUser(u.ID); err != nil {
if errors.Is(err, store.ErrLastGlobal) {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
logf("panel: delete user %d: %v", u.ID, err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
func parseDomainIDs(r *http.Request) []int64 {
var ids []int64
for _, v := range r.PostForm["domain_ids"] {
id, err := strconv.ParseInt(v, 10, 64)
if err == nil && id > 0 {
ids = append(ids, id)
}
}
return ids
}
func domainIDSetFromForm(r *http.Request) map[int64]bool {
m := make(map[int64]bool)
for _, id := range parseDomainIDs(r) {
m[id] = true
}
return m
}
+16
View File
@@ -0,0 +1,16 @@
package handlers
import (
"testing"
"github.com/mixeme/selfpost/internal/web/view"
)
func mustView(t *testing.T) *view.Engine {
t.Helper()
v, err := view.New("test")
if err != nil {
t.Fatalf("view: %v", err)
}
return v
}
-168
View File
@@ -1,168 +0,0 @@
package web
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// handleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials
// (security.md), and until now they could be changed only by recreating the
// state. Changing them here never touches application SASL logins, which are a
// separate identity system (architecture.md § Mail path).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
case http.MethodPost:
s.submitAccount(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// renderAccount draws the settings form. formUsername repopulates the username
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
"Error": formErr,
"Flash": accountFlash(r),
})
}
// accountFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
default:
return ""
}
}
// submitAccount applies a username and/or password change. The current password
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (security.md).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r))
return
}
if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if username == "" {
username = admin.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
return
}
renaming := username != admin.Username
if renaming {
if err := validateUsername(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
// An empty pair of new-password fields means "leave the password alone", so
// the username can be changed on its own.
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
return
}
if err := validateAdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
if !renaming && !repassword {
s.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, a new password, or both.", username)
return
}
hash := admin.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username)
return
}
hash = string(newHash)
}
if err := s.store.UpdateAdmin(username, hash); err != nil {
logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update."
}
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
return
}
// Keep this session usable under the new name, and — when the password
// changed — drop every other session so a cookie captured under the old
// password stops working.
if token, ok := s.sessionToken(r); ok {
if renaming {
s.sessions.Rename(token, username)
}
if repassword {
s.sessions.DestroyOthers(token)
}
}
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
}
// updatedFlag names what changed, for the fixed post-redirect flash message.
func updatedFlag(renamed, repassword bool) string {
switch {
case renamed && repassword:
return "both"
case renamed:
return "username"
default:
return "password"
}
}
-198
View File
@@ -1,198 +0,0 @@
package web
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// The panel session cookie's two possible names. In the production shape —
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
// what the cookie's attributes merely promise into something the browser
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
// attribute, so no other host may set a cookie by this name.
// The prefix is only valid on a Secure cookie, so a development instance on
// plain HTTP has to keep the bare name: with the prefix the browser would
// discard the Set-Cookie outright and logging in would silently never stick.
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
// sessionCookie is the session cookie's name for this deployment.
func (s *Server) sessionCookie() string {
if s.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
// sessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
//
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
// the first match. Two cookies with the same name mean somebody other than
// this panel set one of them — a host on the same registrable domain can,
// with Domain=example.com, and the browser will then send both — and RFC 6265
// makes the older one come first, so "the first match" is precisely the
// attacker's. The value cannot be forged into a valid session, so the effect
// is denial of service, not compromise; refusing the request and saying so in
// the log is what makes it diagnosable instead of an endless login loop. The
// __Host- prefix prevents this outright, but only where it applies — this
// check also covers the plain-HTTP development shape.
func (s *Server) sessionToken(r *http.Request) (string, bool) {
name := s.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
return "", false
}
}
// clearSessionCookies expires the session cookie under both names, so an
// upgrade that switches to the __Host- prefix does not leave the old cookie
// sitting in the browser until it is closed.
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
// The prefixed name is only accepted at all when Secure is set,
// including on the expiring copy.
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// handleLogin serves the login form (GET) and authenticates (POST). Until an
// administrator exists there is nobody to log in, so it points at setup.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := s.store.AdminExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
// No admin yet: login is meaningless. Send a clear message rather than
// a failing form.
s.render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
s.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
s.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
// Active names the page for the layout even though no navigation is drawn
// here: it is what puts page-login on <main>, which the stylesheet uses to
// give the signed-out pages a column the width of their own card.
s.render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"Error": formErr,
})
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (security.md).
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
admin, err := s.store.GetAdmin()
if err != nil {
if !errors.Is(err, store.ErrNoAdmin) {
logf("panel: login: get admin failed: %v", err)
}
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
// Always run bcrypt so timing does not distinguish "wrong user" from
// "wrong password", and compare the username too.
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
if username != admin.Username || pwErr != nil {
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
token := s.sessions.Create(admin.Username)
s.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
// whatever the database row was just set to — at login, and again whenever
// requireAuth extends an active session.
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: s.sessionCookie(),
Value: token,
Path: "/",
MaxAge: s.sessions.MaxAge(),
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// handleLogout destroys the session and clears the cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Destroy every token presented under the session cookie's name: if a
// shadowing duplicate is present (see sessionToken) one of them is the
// real session, and a value that names no session is simply not found.
name := s.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
s.sessions.Destroy(c.Value)
}
}
s.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
-112
View File
@@ -1,112 +0,0 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The __Host- prefix is only valid on a Secure cookie: getting this condition
// backwards would make the development instance fail to log in at all, and
// silently — the browser discards the Set-Cookie and the panel just shows the
// login form again.
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
secure := &Server{cfg: Config{CookieSecure: true}}
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
}
plain := &Server{cfg: Config{CookieSecure: false}}
if got := plain.sessionCookie(); got != "selfpost_session" {
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
}
}
// A neighbouring host on the same registrable domain can set a cookie by the
// same name; the browser then sends both, oldest first. Picking one at random
// would leave the administrator in a login loop with no explanation, so the
// request counts as signed out instead.
func TestSessionTokenRejectsDuplicates(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
if token, ok := s.sessionToken(r); ok {
t.Fatalf("duplicate cookies accepted, token = %q", token)
}
}
func TestSessionTokenReadsOneCookie(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
token, ok := s.sessionToken(r)
if !ok || token != "the-real-session" {
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
}
}
// A cookie under the other deployment's name is not this deployment's session:
// after an upgrade the pre-14 cookie must not be honoured as if it were the
// prefixed one.
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
if _, ok := s.sessionToken(r); ok {
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
}
}
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
reached := false
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if reached {
t.Fatal("the handler ran even though the session cookie was shadowed")
}
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"))
}
}
// Signing out has to expire the cookie under both names, or the cookie left
// over from a pre-__Host- build stays in the browser for the rest of its life.
func TestLogoutClearsBothCookieNames(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com"
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
rec := httptest.NewRecorder()
s.handleLogout(rec, r)
if _, ok := s.sessions.Lookup(token); ok {
t.Error("the session survived sign-out")
}
set := rec.Header().Values("Set-Cookie")
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
var found bool
for _, c := range set {
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
found = true
}
}
if !found {
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
}
}
}
-206
View File
@@ -1,206 +0,0 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
)
// domainRow is one line of the domain list: the stored domain plus the rolled-up
// verdict of its published DNS records, so the operator sees which domains still
// need a record published without opening each one.
type domainRow struct {
store.Domain
DNS health.Status
}
// handleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (product.md).
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "")
}
// renderDashboard renders the domain list. formErr and formName repopulate the
// add-domain form after a rejected submission; flash surfaces a one-shot status
// message keyed by a redirect query flag (never reflected user input).
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
domains, err := s.domains.List()
if err != nil {
logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.render(w, status, "dashboard", map[string]any{
"Title": "SelfPost",
"User": currentUser(r),
"Active": "domains",
"Domains": s.domainRows(domains),
"Error": formErr,
"FormName": formName,
"Flash": dashboardFlash(r),
})
}
// domainRows attaches each domain's DNS verdict to its row. The checks run
// concurrently rather than one after another: each carries its own timeout, so
// in series a dead resolver would multiply that wait by the number of domains
// and the list would look hung. The checker caches results for a few minutes,
// so a repeat view of the list costs no lookups at all, and it is the same
// cache the domain page fills — opening a domain after the list is free.
func (s *Server) domainRows(domains []store.Domain) []domainRow {
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
wg.Add(1)
go func() {
defer wg.Done()
record, err := s.domains.DKIMRecord(d)
if err != nil {
// Without the expected key there is nothing to compare DNS
// against; leave the row unknown rather than accusing the
// domain of a misconfiguration this server caused.
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := s.domainDNS(d, record, false)
rows[i].DNS = dns.Overall
}()
}
wg.Wait()
return rows
}
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func dashboardFlash(r *http.Request) string {
if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
}
return ""
}
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := normalizeDomain(raw)
if err := validateDomain(name); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := s.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
s.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
// panel must explicitly state that all bound applications go with it (product.md).
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
s.render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": currentUser(r),
"Active": "domains",
"Domain": d,
})
}
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
// and returns to the domain list.
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
id, ok := parseDomainID(w, r)
if !ok {
return
}
// Drop any cached DNS verdict for the name while it is still resolvable, so
// re-adding the domain later starts from a fresh check instead of a stale
// one from before it was removed.
if d, err := s.domains.Get(id); err == nil {
defer s.dns.Forget(d.Name)
}
if err := s.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
}
// handleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
// regenerates its files from the database and reloads its daemon, so the
// button doubles as a drift-recovery. The button lives on the status page: it
// is a "put the daemons back in the state the database describes" action,
// which belongs with the rest of the server-health screen rather than in the
// domain list's top bar.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
if err := s.apps.Resync(); err != nil {
logf("panel: manual reload (postfix): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
// bad id or a missing domain and reporting ok=false in that case.
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := s.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/legal"
)
func TestLicenseHandlerServesAGPL(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/license", nil)
handleLicense(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
t.Error("response is missing the AGPL title")
}
if body != string(legal.License) {
t.Error("response body does not match legal.License")
}
}
func TestLicenseHandlerRejectsNonGET(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/license", nil)
handleLicense(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
-158
View File
@@ -1,158 +0,0 @@
package web
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
)
// defaultRateLimitWindowSeconds is the sliding-window length used when an
// admin sets a message ceiling but leaves the window blank (guide § Rate
// limiting, matching the level-1 default hour; guide § Environment variables:
// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
// rateLimitInput is the validated result of a rate-limit form submission.
// clear means "remove the differentiated limit" (guide § Rate limiting: an
// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
ips []string
maxMessages int
windowSeconds int
}
// parseRateLimitForm validates a rate-limit submission on the server
// (security.md). It returns clear=true when the admin removes the limit or
// leaves the IP binding empty; otherwise it requires a positive ceiling and
// window. The returned error's message is safe to show to the admin.
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil {
return rateLimitInput{}, err
}
if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (guide § Rate
// limiting).
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
// whitespace) into a deduplicated list of canonical addresses, rejecting any
// token that is not a valid IP (security.md). The values are only ever stored as
// SQLite parameters and compared in the milter, never written to a config file.
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
})
var out []string
seen := make(map[string]bool)
for _, f := range fields {
ip := net.ParseIP(f)
if ip == nil {
return nil, fmt.Errorf("%q is not a valid IP address", f)
}
c := ip.String()
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
// parsePositiveInt parses a trimmed integer field, returning def when it is
// blank. A non-numeric value returns an error.
func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return def, nil
}
return strconv.Atoi(raw)
}
// handleDomainRateLimit saves or clears a domain-level differentiated rate
// limit (guide § Rate limiting). No reload is needed — the milter reads the
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
in, err := parseRateLimitForm(r)
if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
return
}
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
// handleAppRateLimit saves or clears an application-level differentiated rate
// limit (guide § Rate limiting).
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
return
}
d, err := s.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in, err := parseRateLimitForm(r)
if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
// applyRateLimit dispatches a validated input to the save or clear method of the
// relevant service, keyed by the domain or application id.
func (s *Server) applyRateLimit(
in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) error,
id int64,
) error {
if in.clear {
return clear(id)
}
return save(id, in.ips, in.maxMessages, in.windowSeconds)
}
-99
View File
@@ -1,99 +0,0 @@
package web
import (
"net/http"
"strings"
"golang.org/x/crypto/bcrypt"
)
// handleSetup serves the one-time administrator creation flow at
// /setup/<token> (security.md). Once an administrator exists the whole route
// returns 404; an invalid or expired token is indistinguishable from a missing
// page, also 404.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// Route-specific rate limit, separate from login (security.md).
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
// Reject nested/garbage paths outright.
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !s.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
s.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
s.render(w, status, "setup", map[string]any{
"Title": "SelfPost — Create administrator",
"Active": "setup",
"Token": token,
"Error": formErr,
})
}
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
if err := r.ParseForm(); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validateUsername(username); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validateAdminPassword(password); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
// A concurrent submission may have already created the admin; the
// id=1 / non-empty-table guard makes this the second writer. Treat it
// as "setup already done" rather than an error.
if exists, _ := s.store.AdminExists(); exists {
s.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create admin failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
// Setup is now permanently complete: burn the token (security.md).
s.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
-140
View File
@@ -1,140 +0,0 @@
package web
import (
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/health"
)
// handleStatus renders the server status page: the panel's landing page and the
// one screen that answers "is the service healthy and will mail be accepted".
// The cheap local checks live in the polled "status_body"
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
// because neither belongs on a five-second timer.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
data := s.statusBody()
srv := s.dns.Server(s.cfg.Hostname, false)
data["Title"] = "SelfPost — status"
data["User"] = currentUser(r)
data["Active"] = "status"
data["Flash"] = statusFlash(r)
data["Hostname"] = s.cfg.Hostname
data["PTR"] = srv.PTR
s.render(w, http.StatusOK, "status", data)
}
// handleStatusFragment serves the HTMX polling fragment for the local checks
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
}
// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
// and returns to the page. DNS is the one part of this screen that talks to the
// network, so it refreshes on demand rather than with the poll.
func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
s.dns.Server(s.cfg.Hostname, true)
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
}
// statusBody collects the local checks the fragment renders. Each one
// reports its own problem rather than failing the page, so a broken component
// costs one line and not the whole screen.
func (s *Server) statusBody() map[string]any {
procs, procErr := health.Processes()
procStatus := health.StatusUnknown
if procErr != nil {
// Outside the container (or if the control socket is gone) there is
// nothing to report — "unknown", not "everything is broken".
logf("panel: status: supervisorctl: %v", procErr)
} else {
for _, p := range procs {
procStatus = health.Worst(procStatus, p.Status)
}
}
queueText, queueErr := readQueue()
queueStatus := health.StatusOK
if queueErr != "" {
queueStatus = health.StatusWarn
}
cert := health.CheckCertificate(s.cfg.TLSCertFile)
sockets := []health.Socket{
// OpenDKIM signs every outgoing message and Postfix is configured to
// tempfail without it: a missing socket stops mail.
health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
// The journal-milter only records the send log and fails open.
health.CheckSocket("send-log", s.cfg.JournalSocket, false),
}
socketStatus := health.StatusUnknown
for _, sock := range sockets {
socketStatus = health.Worst(socketStatus, sock.Status)
}
// Resource usage of the machine underneath. It is graded like the rest —
// a processor that is fully busy or a machine out of memory delays or
// kills the mail path — so it counts towards the headline verdict, and
// its rates are measured against the previous poll (internal/health).
machine := s.machine.Sample()
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{
"Processes": procs,
"ProcessError": procErr != nil,
"ProcessStatus": procStatus,
"QueueSummary": queueSummary(queueText),
"QueueError": queueErr,
"QueueStatus": queueStatus,
"Machine": machine,
"Cert": cert,
"Sockets": sockets,
"SocketStatus": socketStatus,
"OverallStatus": overall,
"OverallHeading": overallHeading(overall),
}
}
// queueSummary reduces postqueue's listing to the one line worth showing on
// the status page; the full listing has its own screen (architecture.md §
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
// trailing "-- N Kbytes in M Requests."
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
}
}
return ""
}
// overallHeading turns the worst check into the page's one-line verdict.
func overallHeading(worst health.Status) string {
switch worst {
case health.StatusError:
return "A component needs attention — see the details below."
case health.StatusWarn:
return "Running, with warnings below."
case health.StatusOK:
return "All components are running normally."
default:
return "Some checks could not be performed."
}
}
// statusFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
-55
View File
@@ -1,55 +0,0 @@
package web
import (
"context"
"net/http"
)
type ctxKey int
const usernameKey ctxKey = 0
// requireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers.
//
// It also extends the sliding session (plan B.1) on activity, defined as
// everything except a GET request carrying HX-Request: the four monitoring
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
// /deliveries/rows)
// poll on a timer regardless of whether anyone is looking at the tab, so
// counting those as activity would make "N days idle" mean "N days since a
// browser tab was last open" instead.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := s.sessionToken(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := s.sessions.Lookup(token)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if isSessionActivity(r) && s.sessions.Touch(token) {
s.setSessionCookie(w, token)
}
ctx := context.WithValue(r.Context(), usernameKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// isSessionActivity reports whether a request counts as administrator
// activity for the sliding session timeout, per requireAuth's doc comment.
func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
}
// currentUser returns the authenticated username from the request context.
func currentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok {
return v
}
return ""
}

Some files were not shown because too many files have changed in this diff Show More