Compare commits
14 Commits
d9ab7bfa45
..
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f888dc7be | |||
| 5598b62e4e | |||
| 32f5085c48 | |||
| 5601f73622 | |||
| e17b1680bd | |||
| 5b63da0e49 | |||
| e9aaed1c7b | |||
| 1bd71c10d0 | |||
| 74acf09212 | |||
| ba07decbb0 | |||
| 4fc5cff266 | |||
| ac8c2af500 | |||
| 9e3f0d0fad | |||
| 403c76d17d |
@@ -1,13 +1,9 @@
|
||||
name: release
|
||||
|
||||
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
||||
# Ordinary commits do not publish anything. A pushed tag matching vX.Y.Z does,
|
||||
# and so does workflow_dispatch when it supplies a SemVer X.Y.Z version (or
|
||||
# runs on such a tag). A dispatch from main without that input must fail in
|
||||
# prepare — it must not publish ghcr.io/...:main. The version is the single
|
||||
# source that goes into both the image tag and the panel binary's -ldflags
|
||||
# version, so the two can never drift apart (the invariant restore's version
|
||||
# check in spec 7.5.A depends on).
|
||||
# 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
|
||||
@@ -15,18 +11,17 @@ 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 unless this run is already on a vX.Y.Z tag."
|
||||
required: false
|
||||
description: "Image version as X.Y.Z (no v prefix)."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
@@ -43,15 +38,18 @@ jobs:
|
||||
id: version
|
||||
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:-}"
|
||||
if [ -z "$raw" ] && [[ "${GITHUB_REF_NAME}" == v[0-9]*.[0-9]*.[0-9]* ]]; then
|
||||
raw="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
raw="${raw#v}"
|
||||
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::release version is not SemVer X.Y.Z (ref is ${GITHUB_REF_NAME}; pass inputs.version on workflow_dispatch). Refusing to publish ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}"
|
||||
echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${raw}" >> "$GITHUB_OUTPUT"
|
||||
@@ -69,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
|
||||
|
||||
@@ -133,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
|
||||
@@ -172,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
|
||||
|
||||
+84
-38
@@ -5,6 +5,13 @@ 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
|
||||
@@ -74,24 +81,78 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
through a confirmation page, the same pattern as domain deletion, instead of
|
||||
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 (P0–P7)
|
||||
is closed — and `docs/plans/code-review.md` is deleted per its own exit
|
||||
criteria (history in git and in this file). The
|
||||
[roadmap](docs/roadmap.md)'s recommended order returns to **queue-retries**
|
||||
and then **inbound-relay**.
|
||||
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`. `release.yml` refuses a `workflow_dispatch` whose version is
|
||||
not SemVer `X.Y.Z`, so a run from `main` cannot publish `ghcr.io/...:main`.
|
||||
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
|
||||
`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
|
||||
@@ -108,13 +169,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
downloaded from a running panel through `POST /backup` (plain and encrypted),
|
||||
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, services, 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. 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
|
||||
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
|
||||
@@ -144,13 +207,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
prompts existed — and why that is acceptable (the prompt is a mis-click
|
||||
guard, not an authorization boundary).
|
||||
|
||||
- docs: full-tree review plan (`docs/plans/code-review.md`, since completed
|
||||
and deleted — history in git)
|
||||
— architecture, quality, GUI, tests, licence; P0 is domain-admin send-log
|
||||
authorization. Roadmap queues that plan ahead of inbound-relay and records
|
||||
`schema-squash` (replace the 1.x SQLite migration chain with a 2.x baseline;
|
||||
not a reason to cut a major on its own).
|
||||
|
||||
- docs: security and operator docs updated for the panel that has shipped
|
||||
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
|
||||
@@ -158,15 +214,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
check defends against, and gives a new revisit trigger. Dropped the
|
||||
unimplemented "or argon2" alternative for the password hash.
|
||||
[guide.md](docs/guide.md) documents the Users page and the two roles,
|
||||
the Settings page's default DMARC report address, level-2 rate limiting's
|
||||
fail-open behaviour, that restoring an older backup can resurrect sessions,
|
||||
and that a domain-admin can export working SASL passwords for domains
|
||||
assigned to them. [architecture.md](docs/architecture.md)'s route table now
|
||||
lists `/license` and the `/account` → `/settings` redirect. Fixed the
|
||||
`internal/backup` package comment, which claimed the panel regenerates
|
||||
Postfix/OpenDKIM state from SQLite on every restore boot (it only runs the
|
||||
version guard; drifted daemon state is healed by the Status page's Reload
|
||||
button). Corrected stale `admin.dmarc_report_email` references in
|
||||
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.
|
||||
@@ -181,13 +234,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
longer promises in-panel report reception in a future release — SelfPost
|
||||
does not receive inbound mail.
|
||||
|
||||
- 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.
|
||||
|
||||
## [1.2.5] - 2026-08-13
|
||||
|
||||
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
|
||||
|
||||
@@ -26,7 +26,7 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||
- 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
|
||||
|
||||
@@ -76,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;
|
||||
@@ -95,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.2.5
|
||||
ghcr.io/mixeme/selfpost:1.3.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
@@ -129,136 +130,17 @@ TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
|
||||
only through a reverse proxy on 443 — port 8080 is bound to localhost in the
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:1.2.5
|
||||
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}"
|
||||
|
||||
+58
-20
@@ -154,7 +154,10 @@ 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`.
|
||||
|
||||
@@ -165,17 +168,17 @@ unless noted. The table below is a summary — HTMX fragment endpoints
|
||||
| `/setup/*` | One-time admin bootstrap |
|
||||
| `/login`, `/logout` | Session auth |
|
||||
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
|
||||
| `/status` | 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) |
|
||||
| `/settings` | Admin username/password and DMARC report address |
|
||||
| `/users`, `/users/*` | Panel user CRUD (global admin only) |
|
||||
| `/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
|
||||
@@ -194,13 +197,16 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
||||
absolute cap (regular use keeps the session alive indefinitely).
|
||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||
- **Password change** — all other sessions are deleted; the current session stays
|
||||
active ([internal/store/sessions.go](../internal/store/sessions.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.
|
||||
|
||||
---
|
||||
|
||||
@@ -295,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||
`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 →
|
||||
@@ -319,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`).
|
||||
|
||||
+56
-14
@@ -191,11 +191,12 @@ tag / push only on explicit request (see `release.yml`).
|
||||
|
||||
### Release image
|
||||
|
||||
The release image is published **only** for a SemVer version `X.Y.Z`: a pushed
|
||||
tag `vX.Y.Z`, or a `workflow_dispatch` that supplies that version (or runs on
|
||||
such a tag). 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.
|
||||
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):**
|
||||
|
||||
@@ -203,8 +204,42 @@ tag and `-ldflags` in the binaries so they cannot drift apart.
|
||||
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||
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 release’s 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
|
||||
@@ -290,26 +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` with SemVer
|
||||
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||
|
||||
`prepare` takes the version from the tag (`v1.2.5` → `1.2.5`) or from the
|
||||
`workflow_dispatch` `version` input. A dispatch whose ref is not a `vX.Y.Z`
|
||||
tag and whose input is missing or not `X.Y.Z` fails in `prepare` — it must
|
||||
not publish `ghcr.io/...:main`.
|
||||
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 or workflow_dispatch input)
|
||||
release: published
|
||||
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
||||
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||||
→ 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.
|
||||
|
||||
|
||||
+502
-248
@@ -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)
|
||||
- [Installation](#installation)
|
||||
- [Ports](#ports)
|
||||
- [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)
|
||||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||
- [Published ports](#published-ports)
|
||||
- [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)
|
||||
- [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,119 +196,128 @@ TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||
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. The panel suggests `p=none` (monitoring
|
||||
only, safe to publish immediately). On a send-only relay the sending domain
|
||||
often has no inbox, so `rua=` is optional — configure a default report address
|
||||
in *Settings* or per domain when you have a mailbox that receives inbound mail
|
||||
elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that
|
||||
hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …)
|
||||
cannot be used as external report destinations.
|
||||
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, 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.
|
||||
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 (core and thread counts), memory and swap, and
|
||||
### 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); 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) and trusted-IP application overrides 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`.
|
||||
(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
|
||||
@@ -227,69 +326,88 @@ service healthy and will mail be accepted?"
|
||||
hours. It lives in the data volume, so it survives a container recreate along
|
||||
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).
|
||||
- **Settings** (`/settings`) — change the administrator username and/or
|
||||
password, and the panel-wide default DMARC report address (`rua=`) offered
|
||||
when a domain doesn't set its own — see [DNS setup](#dns-setup). Application
|
||||
SASL logins are separate and are not changed here.
|
||||
- **Users** (`/users`, global administrator only) — create, edit, and delete
|
||||
panel users. There are two roles:
|
||||
|
||||
### 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; `/users`, `/backup`, `/status`, `/mail-queue`, and
|
||||
`/system-log` are not reachable (404). A domain-admin can *export* the
|
||||
domains assigned to them — see the note on working credentials below.
|
||||
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
|
||||
### 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 layers; both can refuse a submission, but only
|
||||
level 2 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).
|
||||
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`
|
||||
@@ -297,81 +415,117 @@ higher numbers).
|
||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||
|
||||
**Level 2 — domain** — optional, on each domain's page. A message ceiling and
|
||||
window for **every** client IP sending as that domain. When unset, only
|
||||
level 1 applies for non-privileged senders.
|
||||
### Full backup and restore
|
||||
|
||||
**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.
|
||||
|
||||
When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is
|
||||
recorded in Deliveries as `rejected`.
|
||||
|
||||
**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 (the Postfix anvil
|
||||
limit above) is the backstop that keeps working even when level 2 cannot run.
|
||||
|
||||
## Backup, restore, and moving a single domain
|
||||
|
||||
Two related but distinct operations
|
||||
([architecture.md](architecture.md) § Persistence):
|
||||
|
||||
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
|
||||
**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:
|
||||
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
|
||||
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.
|
||||
restoring a given backup actually requires (see [Fixed image
|
||||
tag](#fixed-image-tag)).
|
||||
|
||||
Restoring an archive taken **before** you invalidated a session (password
|
||||
change, logout everywhere) can bring that session back: session rows travel
|
||||
with the backup, and a browser that still holds the matching cookie is
|
||||
logged in again once the idle timeout allows it. If a restore might do this,
|
||||
changing every user's password afterwards clears it out.
|
||||
**Restoring in place** (same host — recovering from data loss, or rolling
|
||||
back after a bad change):
|
||||
|
||||
```sh
|
||||
# 1. Stop the instance being replaced
|
||||
docker compose down
|
||||
|
||||
# 2. Move the current /data aside rather than deleting it, start from empty
|
||||
mv ./data ./data.before-restore
|
||||
mkdir ./data
|
||||
|
||||
# 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.
|
||||
Do **not** tar `./data` while the container is running: the database uses
|
||||
WAL mode and a naive copy can capture an inconsistent snapshot. The
|
||||
panel/CLI backup remains preferable when you cannot afford downtime because
|
||||
it takes a consistent SQLite snapshot via the Backup API on a live
|
||||
container.
|
||||
|
||||
- **Export/import a single domain** (domain page → *Export domain* to write the
|
||||
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. *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.
|
||||
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 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.
|
||||
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
|
||||
#### 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:
|
||||
@@ -411,21 +565,121 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
|
||||
|
||||
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||
|
||||
## 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.2.5`. 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).
|
||||
|
||||
+1
-1
@@ -198,7 +198,7 @@ domain-admin's browser sending a request still needs that domain-admin's own
|
||||
cookie, so a token would not add a boundary between roles that the
|
||||
authorization checks (`Principal.CanAccessDomain`,
|
||||
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
|
||||
gating in [internal/web/auth/middleware.go](../internal/web/auth/middleware.go))
|
||||
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
|
||||
don't already enforce. The remaining case is an external site making a
|
||||
logged-in user's browser send a request, which the origin check covers without
|
||||
touching a single template.
|
||||
|
||||
Reference in New Issue
Block a user