Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e163fe123d | ||
|
|
178424eaf6 | ||
|
|
18880a8286 | ||
|
|
1dd0aa9fea | ||
|
|
7c79c085e7 | ||
|
|
1f548dfc7a | ||
|
|
7e4ecf1191 | ||
|
|
1faf91055f | ||
|
|
d46920bb02 | ||
|
|
1a8603b887 | ||
|
|
0cf618438b | ||
|
|
977d16494b | ||
|
|
5a5b642eac | ||
|
|
ebb214b1ef | ||
|
|
43841790d4 | ||
|
|
cf7587754f | ||
|
|
d03ea43969 | ||
|
|
142c1defbb | ||
|
|
2c8c305c75 | ||
|
|
d502611123 | ||
|
|
256d370206 | ||
|
|
6e990cda01 | ||
|
|
997af18065 | ||
|
|
ef57d705d4 | ||
|
|
c4c1f12f3c | ||
|
|
9179818262 | ||
|
|
76f04f93d1 | ||
|
|
9745edd132 | ||
|
|
c0c66dfcdb | ||
|
|
76ad20efdf | ||
|
|
4118e1a8d5 | ||
|
|
0e29acb955 | ||
|
|
c0d9aa7518 | ||
|
|
a92d583053 | ||
|
|
0093878eea | ||
|
|
6a7d010868 | ||
|
|
670982fb3e | ||
|
|
ecc4184324 | ||
|
|
e93a277ee7 | ||
|
|
00983cce39 | ||
|
|
aaf0711326 | ||
|
|
60addd56c8 | ||
|
|
67875e51f3 | ||
|
|
baaed5991b | ||
|
|
7f24b2923c | ||
|
|
b40e14d65c | ||
|
|
865cf67966 | ||
|
|
2640ef4fd6 | ||
|
|
192ab5a1d5 | ||
|
|
0c7f2432f5 | ||
|
|
827fea6ea8 | ||
|
|
0cac2f5ca2 | ||
|
|
3cbdb48fd2 | ||
|
|
8c54bfd5bd | ||
|
|
d35b309714 | ||
|
|
669867cd2d | ||
|
|
50d2624985 | ||
|
|
08943caae1 | ||
|
|
38e9252697 | ||
|
|
64e2ea264c | ||
|
|
610f74d6a4 | ||
|
|
75606dfbe9 | ||
|
|
4003a299a6 | ||
|
|
5e8a330cf1 | ||
|
|
bd4b226953 | ||
|
|
9c1dc4050f | ||
|
|
419af4af95 | ||
|
|
888fc5cbf9 | ||
|
|
f113f44d65 | ||
|
|
e87a55b657 | ||
|
|
337c8e8f0e | ||
|
|
f5f4bd4551 | ||
|
|
e6910c2c2d | ||
|
|
6cf8b7b631 | ||
|
|
a6df2ebceb | ||
|
|
f3bc24b638 | ||
|
|
3eb3e94bce | ||
|
|
db1572d7ad | ||
|
|
538a4b6603 | ||
|
|
db6abaefc7 | ||
|
|
cfd546000a | ||
|
|
e8605e68fa | ||
|
|
de4cc26cdb | ||
|
|
7fd7b1f1de | ||
|
|
25cc34426c | ||
|
|
e51917fcb5 | ||
|
|
934972ce48 | ||
|
|
6c61d53239 | ||
|
|
cdb018d9f1 | ||
|
|
6ff353b94a | ||
|
|
14b4933917 | ||
|
|
7a09e62bf1 | ||
|
|
75e9fef037 | ||
|
|
23ecc5d5af | ||
|
|
c938ed20f8 | ||
|
|
51e20ffc22 | ||
|
|
7b4549a35d | ||
|
|
fc53ae1314 | ||
|
|
558c059341 | ||
|
|
55fe38ba5a | ||
|
|
9dd2b47e6f | ||
|
|
6fe333204b | ||
|
|
ead0638ee2 | ||
|
|
dca83e9671 | ||
|
|
ee8d5f65d9 | ||
|
|
4adbde1948 | ||
|
|
d82d9736bd | ||
|
|
278a88d1e1 | ||
|
|
9dc192d495 | ||
|
|
cc01e44ab8 | ||
|
|
fe2844441b | ||
|
|
e8b558eb3b | ||
|
|
65a420d230 | ||
|
|
61f525e2d7 | ||
|
|
f88d8dabcb | ||
|
|
56a4fa892d | ||
|
|
cb25923a7b | ||
|
|
ab25d24706 | ||
|
|
745f1c03d2 | ||
|
|
6ebb6f56d6 | ||
|
|
4f1f7761a2 | ||
|
|
49ab29b1ce | ||
|
|
2c7f0da3d8 | ||
|
|
c6eeb30258 | ||
|
|
a7a5ad3f91 | ||
|
|
048be22ded | ||
|
|
d72a383904 | ||
|
|
85f3374ce1 | ||
|
|
e6aceeb811 | ||
|
|
cc2d9d43c0 | ||
|
|
b39d1d0302 | ||
|
|
9c31649941 | ||
|
|
75d0329569 | ||
|
|
68c710e99a | ||
|
|
8ad56ed739 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "mockups",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
|
||||
globs: internal/web/view/**,docs/assets/panel-redesign/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Panel design contract
|
||||
|
||||
Source of truth, in this order: the mockups in
|
||||
[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
|
||||
the [components page](docs/assets/panel-redesign/panel/components.html),
|
||||
`panel/outlines.json`, then
|
||||
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
|
||||
**The contract** and **Enforcement** sections before touching a template.
|
||||
|
||||
You are implementing a finished design. You are not designing.
|
||||
|
||||
- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
|
||||
and from the `sp-` components on the components page, through their
|
||||
partials. No other class, no hand-written component markup, no inline style.
|
||||
- No selector in `panel.css` that names a page or an id, uses `!important`, or
|
||||
sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
|
||||
wrong — do not patch it locally.
|
||||
- If a screen seems to need a component, state or layout that does not exist:
|
||||
**stop and ask.** Do not approximate it, and do not substitute a look of
|
||||
your own.
|
||||
- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
|
||||
guard test to make your work pass. A failing guard means the implementation
|
||||
is wrong. Design changes go mockup-first in a separate `design:` commit on
|
||||
the owner's explicit instruction.
|
||||
- "Done" is evidence, not a description: passing `go test ./internal/web/...`
|
||||
output plus a screenshot of each restyled page from the running container,
|
||||
saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
|
||||
Report every difference from the mockup you could not remove; do not
|
||||
describe a different result as the requested one.
|
||||
|
||||
After editing a fragment in `panel/src/`: `python panel/build.py`, then
|
||||
`python panel/check.py` (add `--write` only when the structure change is the
|
||||
point of the commit).
|
||||
@@ -4,10 +4,8 @@
|
||||
.gitattributes
|
||||
dev
|
||||
docs
|
||||
# test/e2e/.stage holds what an e2e run leaves behind (DKIM keys, sasldb2);
|
||||
# the e2e's own sink image is built from test/e2e/sink as its own context.
|
||||
test
|
||||
bin
|
||||
*.exe
|
||||
README.md
|
||||
LICENSE
|
||||
Makefile
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
|
||||
#
|
||||
# Checks every commit of a push or pull request, one by one:
|
||||
#
|
||||
# 1. Design first. A commit that touches the accepted design — the mockups
|
||||
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
|
||||
# touches nothing else under internal/web/. The guards are not adjusted
|
||||
# in the commit they are supposed to judge.
|
||||
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
|
||||
# list is a subset of its parent's. A page that was restyled stays held
|
||||
# to its mockup; its name cannot be put back.
|
||||
# 3. No blob from the past. A template a commit adds or changes is not,
|
||||
# whitespace aside, a template that already existed anywhere in the
|
||||
# history before it. Taking an old page out of git is not a redesign.
|
||||
#
|
||||
# Usage: design-first.sh <base> <head> (commits base..head are checked)
|
||||
# Needs the full history: actions/checkout with fetch-depth: 0.
|
||||
set -euo pipefail
|
||||
|
||||
base="${1:?usage: design-first.sh <base> <head>}"
|
||||
head="${2:?usage: design-first.sh <base> <head>}"
|
||||
|
||||
RATCHET=internal/web/view/legacy_pages.txt
|
||||
TEMPLATES=internal/web/view/templates/
|
||||
ZERO=0000000000000000000000000000000000000000
|
||||
|
||||
# A new branch, or a force push whose old tip is gone, has no usable base:
|
||||
# check the tip commit alone rather than nothing.
|
||||
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
base="$(git rev-parse "$head^" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$base" ]; then
|
||||
commits="$(git rev-list --reverse --no-merges "$base..$head")"
|
||||
else
|
||||
commits="$(git rev-list --reverse --no-merges "$head")"
|
||||
fi
|
||||
|
||||
is_guard() {
|
||||
case "$1" in
|
||||
docs/assets/panel-redesign/panel/*) return 0 ;;
|
||||
.github/scripts/design-first.sh) return 0 ;;
|
||||
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
|
||||
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
|
||||
}
|
||||
|
||||
normalised() { # a blob's hash with all whitespace removed
|
||||
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
|
||||
}
|
||||
|
||||
cache="$(mktemp -d)"
|
||||
trap 'rm -rf "$cache"' EXIT
|
||||
|
||||
past_hash() { # normalised hash of a historical blob, computed once
|
||||
if [ ! -f "$cache/$1" ]; then
|
||||
normalised "$1" > "$cache/$1"
|
||||
fi
|
||||
cat "$cache/$1"
|
||||
}
|
||||
|
||||
fail=0
|
||||
say() {
|
||||
echo "::error::design-first: $*"
|
||||
fail=1
|
||||
}
|
||||
|
||||
for c in $commits; do
|
||||
short="$(git rev-parse --short "$c")"
|
||||
parent="$(git rev-parse --verify --quiet "$c^" || true)"
|
||||
if [ -n "$parent" ]; then
|
||||
changed="$(git diff --name-only --no-renames "$parent" "$c")"
|
||||
else
|
||||
changed="$(git ls-tree -r --name-only "$c")"
|
||||
fi
|
||||
|
||||
# 1. design first
|
||||
guards="" impl=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
if is_guard "$f"; then
|
||||
guards="$guards $f"
|
||||
elif [ "${f#internal/web/}" != "$f" ]; then
|
||||
impl="$impl $f"
|
||||
fi
|
||||
done <<< "$changed"
|
||||
if [ -n "$guards" ] && [ -n "$impl" ]; then
|
||||
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
|
||||
fi
|
||||
|
||||
# 2. the ratchet
|
||||
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
|
||||
# Once the file has existed, a parent without it means an empty list.
|
||||
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
|
||||
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
|
||||
if [ -n "$added" ]; then
|
||||
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. no blob from the past
|
||||
if [ -n "$parent" ]; then
|
||||
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
|
||||
if [ -n "$touched" ]; then
|
||||
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
now="$(normalised "$(git rev-parse "$c:$f")")"
|
||||
for blob in $past; do
|
||||
if [ "$(past_hash "$blob")" = "$now" ]; then
|
||||
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
|
||||
break
|
||||
fi
|
||||
done
|
||||
done <<< "$touched"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
|
||||
@@ -1,28 +1,26 @@
|
||||
name: release
|
||||
|
||||
# Publishes an immutable, version-tagged image on ghcr.io.
|
||||
# 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.
|
||||
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
||||
# Ordinary commits do not publish anything — only a pushed tag matching
|
||||
# vX.Y.Z does. That tag is the single source the version comes from: it goes
|
||||
# into both the image tag and the panel binary's -ldflags version, so the two
|
||||
# can never drift apart (the invariant restore's version check in spec 7.5.A
|
||||
# depends on).
|
||||
#
|
||||
# Native per-architecture builds (see docs/development.md), not qemu: running
|
||||
# the full Postfix and panel stack under emulation for the e2e gate below is
|
||||
# impractically slow. Each arch builds, e2e-gates and pushes its own 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. The per-arch tags stay in GHCR next to the version
|
||||
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
|
||||
# drop a tag without deleting the image behind it.
|
||||
# Native per-architecture builds (see docs/development.md), not qemu:
|
||||
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
||||
# 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).
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version as X.Y.Z (no v prefix)."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -34,25 +32,9 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Derive a SemVer X.Y.Z version
|
||||
- name: Derive version from tag
|
||||
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:-}"
|
||||
fi
|
||||
raw="${raw#v}"
|
||||
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${raw}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
@@ -67,8 +49,6 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -89,27 +69,8 @@ jobs:
|
||||
cache-dependency-path: test/e2e/go.sum
|
||||
|
||||
- name: e2e (gates publishing — see docs/development.md)
|
||||
# The e2e builds selfpost:e2e again through compose.override.yml, and
|
||||
# that rebuild is the image pushed below. Without the version here it
|
||||
# was stamped "e2e" (audit 2026-10-12, H1); the suite now checks the
|
||||
# stamp against this variable.
|
||||
env:
|
||||
SELFPOST_E2E_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||
|
||||
- name: Check the version stamp of the image about to be pushed
|
||||
env:
|
||||
WANT: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for bin in panel selfpost-backup; do
|
||||
got=$(docker run --rm --entrypoint "$bin" selfpost:e2e -version)
|
||||
if [ "$got" != "$WANT" ]; then
|
||||
echo "::error::selfpost:e2e $bin -version = '$got', want '$WANT'; refusing to push"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -152,10 +113,6 @@ 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
|
||||
@@ -195,36 +152,3 @@ jobs:
|
||||
attempt=$((attempt + 1))
|
||||
backoff=$((backoff * 2))
|
||||
done
|
||||
|
||||
- name: Remove orphaned untagged versions from GHCR
|
||||
# Re-publishing a version moves its tags to new manifests and leaves the
|
||||
# old ones behind as untagged "sha256:..." package versions. Delete only
|
||||
# those that no tagged manifest references: the images behind a
|
||||
# multi-arch tag are package versions too, and deleting one breaks pulls
|
||||
# of that tag. Any failed lookup aborts the step before anything is
|
||||
# deleted.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="ghcr.io/${{ github.repository }}"
|
||||
owner="${{ github.repository_owner }}"
|
||||
pkg="${{ github.event.repository.name }}"
|
||||
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||
versions=$(gh api "${api}?per_page=100" --paginate \
|
||||
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
|
||||
keep=$(mktemp)
|
||||
while IFS=$'\t' read -r id digest ntags; do
|
||||
[ "$ntags" -gt 0 ] || continue
|
||||
echo "$digest" >> "$keep"
|
||||
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
|
||||
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
|
||||
done <<<"$versions"
|
||||
while IFS=$'\t' read -r id digest ntags; do
|
||||
[ "$ntags" -eq 0 ] || continue
|
||||
if grep -qxF "$digest" "$keep"; then
|
||||
continue
|
||||
fi
|
||||
echo "deleting orphaned GHCR package version ${id} (${digest})"
|
||||
gh api -X DELETE "${api}/${id}"
|
||||
done <<<"$versions"
|
||||
|
||||
@@ -10,23 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# design-first.sh reads each commit's parent and the template history.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Design first
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
test/e2e/go.sum
|
||||
|
||||
- name: Format
|
||||
run: |
|
||||
@@ -40,24 +28,5 @@ jobs:
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# Unused code, dead branches and the usual slips go vet does not look
|
||||
# for. The version is pinned: a new release adds checks, and a check
|
||||
# that appears under a push is a red build nobody asked for. 2026.1 is
|
||||
# the release for Go 1.26. test/e2e is a module of its own, so ./...
|
||||
# skips it; it is checked in its own step below.
|
||||
- name: Staticcheck
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
# The e2e module runs only in release.yml, after a release is published;
|
||||
# a type error there would first show up then. Compile it here without
|
||||
# running it: `go test -run` would still run TestMain, which brings up
|
||||
# the whole stand.
|
||||
- name: Compile the e2e
|
||||
working-directory: test/e2e
|
||||
run: |
|
||||
go vet ./...
|
||||
go test -c -o /dev/null .
|
||||
go run honnef.co/go/tools/cmd/staticcheck@2026.1 ./...
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# SelfPost build.
|
||||
#
|
||||
# The version is stamped into both binaries and MUST match the Docker image tag
|
||||
# (architecture.md § Persistence: backup/restore compatibility check). Override
|
||||
# on the command line:
|
||||
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
|
||||
#
|
||||
# make build VERSION=1.3.0
|
||||
#
|
||||
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||
# Go, so no cgo is required).
|
||||
# Go, so no cgo is required) — see spec 7.1.
|
||||
|
||||
VERSION ?= dev
|
||||
MODULE := github.com/mixeme/selfpost
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
SelfPost
|
||||
Copyright (C) 2026 Mikhail Yenuchenko
|
||||
|
||||
This product is licensed under the GNU Affero General Public License,
|
||||
version 3. The full licence text is in the LICENSE file in this
|
||||
distribution (and at /license in the control panel).
|
||||
|
||||
Corresponding Source for the published releases is at:
|
||||
https://github.com/mixeme/selfpost
|
||||
|
||||
If you modify SelfPost and let users interact with it over a network,
|
||||
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||
your modified version. Update SourceURL in internal/legal/legal.go
|
||||
so the panel footer points at your sources.
|
||||
|
||||
Third-party software included in this distribution
|
||||
----------------------------------------------------
|
||||
|
||||
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||
Copyright (c) Big Sky Software
|
||||
Licence: Zero-Clause BSD (0BSD)
|
||||
https://github.com/bigskysoftware/htmx
|
||||
|
||||
Bulma 1.0.4 (internal/web/view/static/bulma.min.css)
|
||||
Copyright (c) 2023 Jeremy Thomas
|
||||
Licence: MIT
|
||||
https://github.com/jgthms/bulma
|
||||
Distributed unmodified.
|
||||
|
||||
Tabler Icons 3.49.0
|
||||
Copyright (c) 2020-2026 Paweł Kuna
|
||||
Licence: MIT
|
||||
https://github.com/tabler/tabler-icons
|
||||
Distributed as two files:
|
||||
internal/web/view/static/tabler-icons.css
|
||||
internal/web/view/static/tabler-icons.woff2
|
||||
Webfont subset to the 57 icons the panel uses, made with fontTools
|
||||
pyftsubset.
|
||||
|
||||
IBM Plex Sans / IBM Plex Mono
|
||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||
Licence: SIL Open Font License 1.1
|
||||
Full text: internal/web/view/static/OFL.txt
|
||||
(also /usr/share/doc/selfpost/OFL.txt in the image, and
|
||||
/static/OFL.txt from the control panel)
|
||||
https://github.com/IBM/plex
|
||||
Distributed unmodified as three latin-subset WOFF2 files served by the
|
||||
control panel:
|
||||
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
|
||||
internal/web/view/static/ibm-plex-mono-400.woff2
|
||||
internal/web/view/static/ibm-plex-mono-600.woff2
|
||||
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
|
||||
the Google Fonts distribution. The wordmarks in the shipped SVG logos
|
||||
are outlined paths derived from the same fonts.
|
||||
|
||||
Go module dependencies are listed with their licences in
|
||||
docs/development.md § External libraries. Debian packages in the
|
||||
runtime image carry their own copyright files as shipped by Debian.
|
||||
@@ -4,16 +4,13 @@
|
||||
|
||||
# SelfPost
|
||||
|
||||
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
||||
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
|
||||
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
|
||||
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
|
||||
then point your apps at the SMTP endpoint.
|
||||
|
||||
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
|
||||
configure domains, DKIM keys, and SASL applications once, then point your apps
|
||||
at the SMTP endpoint.
|
||||
|
||||
SelfPost sends mail straight to the internet from **your own IP**, with
|
||||
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
|
||||
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
|
||||
mail.
|
||||
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
||||
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
||||
|
||||
**For:** operators who run their own VPS or home server and want a simple relay
|
||||
they control, without a third-party SMTP provider.
|
||||
@@ -24,16 +21,12 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
## Features
|
||||
|
||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
|
||||
- Optional DMARC aggregate report ingest on port 25 (off by default)
|
||||
- In-panel Help: a page of topics, opened from the `?` in the head of a box
|
||||
- Per-application client IP allow-list for SMTP AUTH (optional)
|
||||
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
|
||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
|
||||
- Full-server backup and single-domain export/import (optional password encryption)
|
||||
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
||||
- Single Docker image; data in a `./data` bind mount
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -42,14 +35,11 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
||||
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||
|
||||
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||
the private reporting channel and the scope.
|
||||
|
||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||
the `ghcr.io/mixeme/selfpost` image.
|
||||
|
||||
@@ -83,46 +73,26 @@ For every domain you add in the panel:
|
||||
- [ ] DKIM TXT record (value shown on the domain page)
|
||||
- [ ] DMARC `_dmarc` TXT record
|
||||
|
||||
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
|
||||
|
||||
### Per inbound domain (optional)
|
||||
|
||||
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
|
||||
|
||||
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
|
||||
if this host is backup-MX)
|
||||
|
||||
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
|
||||
See [DNS setup](docs/guide.md#dns-setup) 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: [Full deployment](docs/guide.md#full-deployment) in the
|
||||
> operator guide.
|
||||
> Production deploy: [step 3](#3-start-selfpost).
|
||||
|
||||
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;
|
||||
outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
|
||||
> **The 2.x line is not published on ghcr.io yet** (the newest image there is
|
||||
> `1.3.0`). Until it is, build the image from this checkout under the name the
|
||||
> commands below and `deploy/docker-compose.yml` use; Docker then takes the
|
||||
> local image and pulls nothing:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.7.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.7.0 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
docker run --rm -d --name selfpost-try \
|
||||
-p 127.0.0.1:8080:8080 \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:2.7.0
|
||||
ghcr.io/mixeme/selfpost:1.0.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
@@ -156,25 +126,139 @@ 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.
|
||||
|
||||
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).
|
||||
| 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/) |
|
||||
|
||||
The compose file always publishes **465**, **587**, and **25**; Postfix listens
|
||||
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
|
||||
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_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).
|
||||
### 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).
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2026 Mikhail Yenuchenko.
|
||||
|
||||
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
||||
version as a network-accessible service, you must make the modified source
|
||||
available to its users — not only when you distribute copies of the code.
|
||||
Third-party notices: [NOTICE](NOTICE).
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||
|
||||
| Version | Supported |
|
||||
|---|---|
|
||||
| latest 1.x | yes |
|
||||
| earlier 1.x | no — upgrade first |
|
||||
| 0.x | no (pre-release) |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||
the *Report a vulnerability* button under the repository's
|
||||
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||
it, mail `public@mixeme.ru` instead.
|
||||
|
||||
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||
|
||||
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||
deadline that cannot be honoured is worse than none. Reports are read and
|
||||
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||
with the timeline agreed in the thread.
|
||||
|
||||
Disclosure is coordinated by request, not by demand: please hold public details
|
||||
until a patch is out. If you get no reply, that is not a request for a
|
||||
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||
the CHANGELOG unless they ask not to be.
|
||||
|
||||
## In scope
|
||||
|
||||
The relay's job is to accept authenticated mail from an application and hand it
|
||||
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||
scope:
|
||||
|
||||
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||
a domain the sending application is not bound to
|
||||
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||
from anything the container exposes
|
||||
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||
it was not granted
|
||||
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||
tokens, or backup encryption material leaking to an unauthorised party
|
||||
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||
CSRF on state-changing routes, privilege escalation
|
||||
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||
per-domain and per-application limits
|
||||
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||
user to root
|
||||
|
||||
## Out of scope
|
||||
|
||||
These are the operator's responsibility or accepted trade-offs, documented in
|
||||
[docs/security.md](docs/security.md) and the
|
||||
[operator guide](docs/guide.md):
|
||||
|
||||
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||
wrong PTR record, DNS records not published, a self-signed or expired
|
||||
certificate on the reverse proxy
|
||||
- Anything requiring the attacker to already have root on the host or write
|
||||
access to the `./data` bind mount
|
||||
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||
never terminates HTTPS itself
|
||||
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||
a policy decision of that provider, not a defect
|
||||
- Denial of service through sheer volume against a single-tenant relay
|
||||
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||
those upstream; if SelfPost's configuration makes an upstream issue
|
||||
exploitable when it otherwise would not be, that *is* in scope
|
||||
|
||||
## Reports we cannot act on
|
||||
|
||||
Automated scanner output with no demonstrated impact, and reports whose only
|
||||
content is a version number compared against a CVE list, are closed without
|
||||
investigation.
|
||||
@@ -1,16 +1,16 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# SelfPost — single Debian-slim image running postfix + panel under
|
||||
# supervisord. Build from the repository root:
|
||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||
# supervisord (spec 4). Build from the repository root:
|
||||
#
|
||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
|
||||
# ---- build stage -------------------------------------------------------------
|
||||
FROM golang:1.26-trixie AS build
|
||||
FROM golang:1.26-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Version stamped into both binaries; MUST match the image tag.
|
||||
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||
ARG VERSION=dev
|
||||
|
||||
# Module metadata first for layer caching.
|
||||
@@ -30,156 +30,85 @@ RUN go vet ./... \
|
||||
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||
|
||||
# ---- runtime stage -----------------------------------------------------------
|
||||
FROM debian:trixie-slim AS runtime
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Preseed Postfix so its install is non-interactive and yields a working
|
||||
# main.cf. The real relay configuration is generated by the panel.
|
||||
# (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50:
|
||||
# postfix/main_mailer_type and postfix/mailname are the questions the install
|
||||
# asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
|
||||
# configuration".)
|
||||
# openssl is named because postfix-cert-reload.sh runs it, not left to arrive
|
||||
# with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
|
||||
# debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
|
||||
# saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
|
||||
# debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
|
||||
# (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
|
||||
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
|
||||
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
|
||||
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
|
||||
# the panel runs (internal/app/sasl.go); that db-util ships that path on
|
||||
# trixie is not in a source read here - the image build and the e2e sasldb
|
||||
# test say whether it does.
|
||||
# No DKIM package is installed: the panel signs itself (internal/milter,
|
||||
# sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
|
||||
# the image in 2.8.0 (docs/plans/panel-dkim-signing.md).
|
||||
# systemd-standalone-sysusers is named so that cron-daemon-common, which
|
||||
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
|
||||
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
|
||||
# than with the whole of systemd, the first alternative of its "Depends:
|
||||
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
|
||||
# image, 2026-10-15). Nothing here runs systemd.
|
||||
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postfix \
|
||||
opendkim \
|
||||
opendkim-tools \
|
||||
sasl2-bin \
|
||||
libsasl2-modules \
|
||||
db-util \
|
||||
supervisor \
|
||||
logrotate \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
curl \
|
||||
systemd-standalone-sysusers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
|
||||
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch); with no service
|
||||
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
|
||||
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
|
||||
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
|
||||
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
|
||||
# Unprivileged user for the panel process (spec 7.6.8).
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Unprivileged user for the panel process, with ids of its own outside the
|
||||
# system range for the same reason as selfpost's below: its files under
|
||||
# /data show on the host by number, and a number from that range is a
|
||||
# system group of the host's too (useradd gave the panel group 998 in the
|
||||
# trixie image, systemd-network on the test server's host; bookworm's
|
||||
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
|
||||
# back to it by name on every start, so files of the old ids follow.
|
||||
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Shared group bridging the unprivileged services: the panel writes the
|
||||
# application SASL accounts (sasldb2) and the Postfix sender map and opens the
|
||||
# milter sockets, while Postfix (another user) must read and reach them.
|
||||
# Membership in this group - plus setgid dirs under /data and /run/selfpost
|
||||
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
|
||||
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
|
||||
# supervisor control socket has a group of its own, `supervisor`, below.)
|
||||
# The id is fixed, because the host sees it: an operator gives the TLS
|
||||
# private key on the host to this group by number, so that the panel can put
|
||||
# it into a full backup (guide § Taking a full backup). It lies outside the
|
||||
# range the distribution hands out to system groups, 100-999 (adduser 3.152,
|
||||
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
|
||||
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
|
||||
# read in the image, 2026-10-15), so no package can take it, and far above
|
||||
# the 1000 a host starts its users' groups at, so a host is unlikely to have
|
||||
# a group of that number that would read the key too. Until 2.7.0 it was
|
||||
# 998, inside that range: in the trixie image systemd took 998 for
|
||||
# systemd-network and the build failed, and on the test server's host 998
|
||||
# is systemd-network too (seen 2026-10-15).
|
||||
# groupadd(8) refuses a --gid already in use, so an id taken after all would
|
||||
# fail this build rather than share the group.
|
||||
RUN groupadd --system --gid 10998 selfpost \
|
||||
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
||||
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
||||
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
||||
# them. Membership in this group — plus setgid dirs under /data (set up in
|
||||
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||
RUN groupadd --system selfpost \
|
||||
&& usermod -aG selfpost panel \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (and the status page's
|
||||
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
||||
# needed to reach Postfix's showq socket - but the documented deployment runs
|
||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||
# grants the same access without relying on a setgid escalation the hardening
|
||||
# deliberately forbids. The same membership is what lets the panel submit its
|
||||
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
|
||||
# to write the maildrop directory (internal/notify/mailer.go). It does not let
|
||||
# the panel bypass any Postfix restriction that a local user does not already
|
||||
# have through the world-executable sendmail.
|
||||
# (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
|
||||
# root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
|
||||
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst:
|
||||
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
|
||||
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
||||
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
||||
# it the group needed to reach Postfix's showq socket — but the documented
|
||||
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
||||
# so the panel would always see "Permission denied". Making `panel` a real
|
||||
# member of postdrop grants the same access without relying on a setgid
|
||||
# escalation the hardening deliberately forbids. postdrop membership is
|
||||
# read-side only: it does not let the panel bypass any Postfix restriction that
|
||||
# a local user does not already have through the world-executable sendmail.
|
||||
RUN usermod -aG postdrop panel
|
||||
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
|
||||
# the panel alone - not Postfix's user, which shares `selfpost` for the files
|
||||
# under /data.
|
||||
RUN groupadd --system supervisor \
|
||||
&& usermod -aG supervisor panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
RUN mkdir -p /run/selfpost /data \
|
||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||
&& chown opendkim:opendkim /run/opendkim \
|
||||
&& chown panel:panel /run/selfpost /data
|
||||
|
||||
COPY --from=build /out/panel /usr/local/bin/panel
|
||||
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||
|
||||
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||
# serves the same text at /license from an embedded copy. OFL.txt is the SIL
|
||||
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
|
||||
# embedded in the panel binary (NOTICE).
|
||||
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
|
||||
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
|
||||
COPY build/opendkim.conf /etc/opendkim.conf
|
||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||
COPY build/crashexit.py /usr/local/bin/crashexit.py
|
||||
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \
|
||||
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
|
||||
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
|
||||
|
||||
# File modes are pinned here so a build context copied from a checkout without
|
||||
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||
# that logrotate would silently ignore — see docs/development.md § Building
|
||||
# binaries and the image.
|
||||
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
|
||||
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||
# which needs no inbound listener or EXPOSE.
|
||||
EXPOSE 8080 465 587
|
||||
|
||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
|
||||
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
|
||||
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
||||
EXPOSE 8080 465 587 25
|
||||
|
||||
# Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
|
||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||
# supervisord, which becomes PID 1 and owns process supervision.
|
||||
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||
|
||||
If a managed program of the mail path exhausts its restart retries and
|
||||
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
|
||||
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
|
||||
terminate, so the whole container exits and Docker's `restart` policy starts it
|
||||
again, rather than lingering "alive" with a dead Postfix or panel.
|
||||
FATAL alone did not cover the second: a program reaches FATAL only through
|
||||
exits before its startsecs, and one that crashed after it was restarted for
|
||||
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
|
||||
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
|
||||
it would ignore) stays on Server > Health, and mail keeps flowing
|
||||
(assumptions C-V5).
|
||||
If any managed program exhausts its restart retries and enters FATAL, this
|
||||
listener signals supervisord (its parent) to terminate, so the whole container
|
||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
|
||||
|
||||
Communication with supervisord uses the event listener protocol over stdin and
|
||||
stdout, so stdout must carry only protocol tokens.
|
||||
|
||||
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
|
||||
listener writes READY\\n, reads one header line of key:value tokens whose len
|
||||
is the payload's length and eventname the event's type, reads the payload, and
|
||||
answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
|
||||
"processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
|
||||
adds "expected:0" for an exit code the program's exitcodes do not list, "1"
|
||||
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
|
||||
supervisord starts the listener as its own child, and on SIGTERM it shuts down
|
||||
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
|
||||
|
||||
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
|
||||
for the test (build/crashexit_test.go).
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Programs whose failure does not take the container down
|
||||
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
|
||||
# runs on demand - a reload it could not start is reported to whoever asked for
|
||||
# it - and cert-reload looks for a renewed certificate once a day (a crash of
|
||||
# it leaves the one Postfix serves).
|
||||
SPARED = {"logrotate", "postfix-reload", "cert-reload"}
|
||||
|
||||
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
|
||||
# program that does not stay up. A program on the mail path restarts in
|
||||
# seconds; five times in ten minutes is no passing fault.
|
||||
CRASH_LIMIT = 5
|
||||
CRASH_WINDOW = 600
|
||||
|
||||
|
||||
def write_stdout(s):
|
||||
@@ -53,20 +19,7 @@ def write_stdout(s):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def say(s):
|
||||
sys.stderr.write("crashexit: " + s + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def shut_down():
|
||||
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
|
||||
say("would shut down")
|
||||
return
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def main():
|
||||
crashes = {} # program -> times of its unexpected exits in the window
|
||||
while True:
|
||||
# Tell supervisord we are ready for the next event.
|
||||
write_stdout("READY\n")
|
||||
@@ -76,37 +29,16 @@ def main():
|
||||
return
|
||||
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||
payload_len = int(headers.get("len", 0))
|
||||
payload = sys.stdin.read(payload_len) if payload_len else ""
|
||||
tokens = dict(
|
||||
pair.split(":", 1) for pair in payload.split() if ":" in pair
|
||||
)
|
||||
name = tokens.get("processname", "")
|
||||
event = headers.get("eventname", "PROCESS_STATE_FATAL")
|
||||
if payload_len:
|
||||
sys.stdin.read(payload_len)
|
||||
|
||||
if event == "PROCESS_STATE_EXITED":
|
||||
if name not in SPARED and tokens.get("expected") == "0":
|
||||
now = time.monotonic()
|
||||
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
|
||||
times.append(now)
|
||||
crashes[name] = times
|
||||
if len(times) >= CRASH_LIMIT:
|
||||
say(
|
||||
"%s exited unexpectedly %d times in %d seconds; shutting down container"
|
||||
% (name, len(times), CRASH_WINDOW)
|
||||
)
|
||||
shut_down()
|
||||
crashes[name] = []
|
||||
elif name in SPARED:
|
||||
say(
|
||||
"%s entered FATAL; it is not on the mail path, the container stays up"
|
||||
% name
|
||||
)
|
||||
else:
|
||||
say(
|
||||
"%s entered FATAL; shutting down container"
|
||||
% (name or "a managed process")
|
||||
)
|
||||
shut_down()
|
||||
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||
# program can no longer be restarted. Take the container down.
|
||||
sys.stderr.write(
|
||||
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
|
||||
write_stdout("RESULT 2\nOK")
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Package build holds the image's scripts; this file tests crashexit.py, the
|
||||
// supervisor event listener that takes the container down.
|
||||
package build
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// event is one event as supervisord writes it to a listener's stdin
|
||||
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
|
||||
// payload's length, then the payload).
|
||||
func event(name, payload string) string {
|
||||
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
|
||||
}
|
||||
|
||||
// runListener feeds events to crashexit.py in dry run and returns what it
|
||||
// wrote to stderr.
|
||||
func runListener(t *testing.T, events ...string) string {
|
||||
t.Helper()
|
||||
// python3 on Linux; on Windows "python3" may be the Store's stub, which
|
||||
// runs nothing.
|
||||
names := []string{"python3", "python"}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = []string{"python", "python3"}
|
||||
}
|
||||
python := ""
|
||||
for _, n := range names {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
python = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if python == "" {
|
||||
t.Skip("no python")
|
||||
}
|
||||
cmd := exec.Command(python, "crashexit.py")
|
||||
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
|
||||
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
|
||||
}
|
||||
// Python on Windows writes its text stdout with \r\n.
|
||||
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
|
||||
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// A program on the mail path that keeps crashing after its startsecs - never
|
||||
// FATAL - takes the container down at the fifth unexpected exit (audit
|
||||
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
|
||||
func TestCrashexitCountsCrashes(t *testing.T) {
|
||||
exit := func(name, expected string) string {
|
||||
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
|
||||
}
|
||||
var four []string
|
||||
for i := 0; i < 4; i++ {
|
||||
four = append(four, exit("panel", "0"))
|
||||
}
|
||||
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("four crashes shut down:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("five crashes did not shut down:\n%s", got)
|
||||
}
|
||||
var calm []string
|
||||
for i := 0; i < 6; i++ {
|
||||
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
|
||||
}
|
||||
calm = append(calm, exit("postfix", "0"), exit("postfix", "0"), exit("postfix", "0"))
|
||||
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FATAL of a mail-path program shuts down at once; of a spared one, not.
|
||||
func TestCrashexitFatal(t *testing.T) {
|
||||
fatal := func(name string) string {
|
||||
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
|
||||
}
|
||||
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
|
||||
t.Errorf("postfix FATAL:\n%s", got)
|
||||
}
|
||||
if got := runListener(t, fatal("logrotate"), fatal("cert-reload")); strings.Contains(got, "would shut down") {
|
||||
t.Errorf("a spared program's FATAL shut down:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@ set -e
|
||||
|
||||
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
||||
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
|
||||
# fall back independently when it's unset — to `localhost` and to the container
|
||||
# hostname respectively — so accounts get written under one realm and looked up
|
||||
# under another and authentication silently fails for every application, while
|
||||
# HELO also stops matching the PTR record and mail that does go out lands in
|
||||
# spam. No fallback can be correct, so fail loudly here, before either side of
|
||||
# that split has a chance to run, rather than leave a green panel with broken
|
||||
# mail.
|
||||
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||
# postfix-config.sh each fall back independently when it's unset — to
|
||||
# `localhost` and to the container hostname respectively — so accounts get
|
||||
# written under one realm and looked up under another and authentication
|
||||
# silently fails for every application, while HELO also stops matching the
|
||||
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||
# correct, so fail loudly here, before either side of that split has a chance
|
||||
# to run, rather than leave a green panel with broken mail.
|
||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||
cat >&2 <<'EOF'
|
||||
FATAL: SELFPOST_HOSTNAME is not set.
|
||||
@@ -52,86 +52,44 @@ case "$SELFPOST_HOSTNAME" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# The persistent root /data is a host bind mount, so it arrives owned by the
|
||||
# host user (typically root), not by the unprivileged panel user that actually
|
||||
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
|
||||
# — the one place still running as root — before handing off to supervisord,
|
||||
# which starts the panel as the panel user.
|
||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||
# by the host user (typically root), not by the unprivileged panel user that
|
||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||
# Fix its ownership here — the one place still running as root — before handing
|
||||
# off to supervisord, which starts the panel as the panel user.
|
||||
#
|
||||
# Mode must stay world-traversable (0755): Postfix reaches its trees under
|
||||
# /data as another user. Go's testing.TempDir is 0700, and a bare chown would
|
||||
# leave that mode in place — OpenDKIM, which read its tables there until
|
||||
# 2.8.0, then could not, and the container crash-looped (e2e
|
||||
# TestHostnameGate/valid_hostname_starts).
|
||||
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
|
||||
# mode 0o700".)
|
||||
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||
chown panel:panel /data
|
||||
chmod 755 /data
|
||||
|
||||
# What follows runs as root over a tree the panel user writes: /data is the
|
||||
# panel's, and so is /data/postfix. A symbolic link the panel put where a
|
||||
# directory or a file of its own is expected would lead root's mkdir, ": >",
|
||||
# chmod and chgrp to whatever it names (chmod follows a link; so do a
|
||||
# redirect and mkdir -p; chown -R and find do not descend into one given as
|
||||
# their operand). None of these paths is ever a link in a working
|
||||
# installation, so a link there is removed and the path made anew below.
|
||||
# (Security review of base-trixie, 2026-10-15: no escalation found, as the
|
||||
# TLS key copy and the read-only mounts stand, but nothing for root to
|
||||
# follow either.)
|
||||
drop_links() {
|
||||
for p in "$@"; do
|
||||
if [ -L "$p" ]; then
|
||||
echo "entrypoint: removing the symbolic link $p" >&2
|
||||
rm -f "$p"
|
||||
fi
|
||||
done
|
||||
}
|
||||
drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
|
||||
/data/dkim/keys /data/dkim/selectors /data/opendkim/keys \
|
||||
/data/sasl/sasldb2 /data/postfix/queue \
|
||||
/data/postfix/sender_login_maps /data/postfix/relay_domains \
|
||||
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
|
||||
/data/log/mail.log
|
||||
# Restored backups or previously-created state may contain panel-owned files
|
||||
# under /data; make sure they stay writable without disturbing anything that a
|
||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||
# normalised on its own below, as are the trees shared through group selfpost
|
||||
# and the one OpenDKIM read until 2.8.0, which is left as it was (below).
|
||||
# A file's owner is a number: an entry whose user or group is not the panel's
|
||||
# by that number - unpacked by root, or written under another image whose
|
||||
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
|
||||
# handed over whole.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \
|
||||
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
|
||||
# normalised on its own below.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
|
||||
|
||||
# DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
|
||||
# the keys, writes the table and signs (internal/domain, Keys; internal/milter,
|
||||
# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with
|
||||
# OpenDKIM through group selfpost. The first start without /data/dkim copies
|
||||
# them over and leaves the old tree as it was, for an image of 2.7.x rolled
|
||||
# back to (docs/plans/panel-dkim-signing.md, Q2). The copy is made as the
|
||||
# panel user, which can read nothing there the panel could not, into a
|
||||
# directory beside, renamed into place when it is whole: a start that stops
|
||||
# midway copies again. A copy that fails stops the start (set -e) - the
|
||||
# panel would otherwise make the server's own name a new key, and its
|
||||
# published record would no longer match. The selector table is written by
|
||||
# the panel before its signer serves (cmd/panel/signer.go).
|
||||
# (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
|
||||
# -P: never follow a symbolic link in the source.)
|
||||
rm -rf /data/dkim.new
|
||||
if [ ! -e /data/dkim ] && [ -d /data/opendkim/keys ]; then
|
||||
setpriv --reuid=panel --regid=panel --clear-groups -- \
|
||||
sh -c 'mkdir /data/dkim.new && cp -RP /data/opendkim/keys /data/dkim.new/keys'
|
||||
mv /data/dkim.new /data/dkim
|
||||
echo "entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback" >&2
|
||||
fi
|
||||
mkdir -p /data/dkim/keys
|
||||
chown -R panel:panel /data/dkim
|
||||
find /data/dkim -type d -exec chmod 0700 {} +
|
||||
find /data/dkim -type f -exec chmod 0600 {} +
|
||||
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||
# just restored from a backup:
|
||||
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||
# inherits the shared group OpenDKIM traverses;
|
||||
# - private keys and tables group-readable (0640);
|
||||
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||
# daemon comes up cleanly with no domains yet.
|
||||
mkdir -p /data/opendkim/keys
|
||||
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
|
||||
[ -e "$t" ] || : > "$t"
|
||||
done
|
||||
chown -R panel:selfpost /data/opendkim
|
||||
find /data/opendkim -type d -exec chmod 2750 {} +
|
||||
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||
|
||||
# Application SASL accounts. The panel (user `panel`) writes the
|
||||
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
|
||||
# setgid directory so new files inherit the group, and the database itself
|
||||
@@ -140,84 +98,45 @@ mkdir -p /data/sasl
|
||||
chown -R panel:selfpost /data/sasl
|
||||
chmod 2750 /data/sasl
|
||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||
# The copy a password change is made on (internal/app, replaceSASLDB), left
|
||||
# by a panel killed mid-change: a second copy of every password, which the
|
||||
# next change would remove anyway.
|
||||
rm -f /data/sasl/sasldb2.new
|
||||
|
||||
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||
mkdir -p /data/postfix/queue
|
||||
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
||||
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
||||
# references it never fails on a missing file, and keep it group-readable.
|
||||
mkdir -p /data/postfix
|
||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
|
||||
# Empty files keep postfix check happy if the flag is on before any domain exists.
|
||||
for f in relay_domains transport relay_recipients tls_policy; do
|
||||
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
||||
done
|
||||
# Every map here is the panel's to write and Postfix's to read - the sender
|
||||
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
|
||||
# handed to the panel, group selfpost, whatever its name. A restored archive's
|
||||
# files arrive owned by whoever unpacked them (a backup records no owner, and
|
||||
# tar run as root makes them root's), and the first start after a restore
|
||||
# reads every one of them to compare it with the archive's record: one left
|
||||
# to root alone is a restore refused on every start (review of audit stage 3,
|
||||
# H1 - the DMARC maps were not among the names listed here).
|
||||
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
|
||||
chown panel:selfpost /data/postfix
|
||||
chown -R panel:selfpost /data/postfix
|
||||
chmod 2750 /data/postfix
|
||||
|
||||
# Filter lists for the external spam filter (written by the panel when
|
||||
# INBOUND_RELAY_ENABLE=true, read by the antispam sidecar, which mounts this
|
||||
# directory). The sidecar runs under a user of its own in another container,
|
||||
# so the directory and its files are readable by anyone: they hold sender
|
||||
# addresses and networks, no secrets.
|
||||
mkdir -p /data/antispam
|
||||
chown -R panel:selfpost /data/antispam
|
||||
chmod 0755 /data/antispam
|
||||
chmod 0640 /data/postfix/sender_login_maps
|
||||
|
||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||
# `postfix`; the panel reads it for the log-tailer and the System log page. It
|
||||
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||
# resolve a "queued" send-log row survive a container recreate.
|
||||
#
|
||||
# postlogd creates a missing log itself, by default at 0600, which the
|
||||
# unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
|
||||
# maillog_file_permissions); so create it here (and re-normalise an existing
|
||||
# one, plus whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# directory keeps the shared group on anything created inside it later, and
|
||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||
# rotate a log whose directory is writable by a non-root group.
|
||||
# Postfix 3.10.13: src/util/logwriter.c:99, logwriter_open_or_die(), creates the
|
||||
# file with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (proto/postconf.proto:18500, default 0600; Postfix 3.9 and later).
|
||||
# logrotate 3.22.0, logrotate.c:1406-1408, findNeedRotating(): "skipping ...
|
||||
# because parent directory has insecure permissions" when the directory's
|
||||
# group is not root and has write permission.
|
||||
mkdir -p /data/log
|
||||
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||
chown -R postfix:selfpost /data/log
|
||||
chmod 2750 /data/log
|
||||
find /data/log -type f -exec chmod 0640 {} +
|
||||
|
||||
# The milter socket directory. Postfix (user `postfix`) must actually CONNECT
|
||||
# to the panel's milter sockets, not just probe them at start-up. The panel
|
||||
# user creates them, so they are bridged to `postfix` through the shared
|
||||
# `selfpost` group: a group-owned + setgid dir means each socket created
|
||||
# inside inherits group `selfpost`, and group-traversable (2750) lets postfix
|
||||
# reach it. Without this, smtpd cannot talk to the signer and, because
|
||||
# signing is strict (default_action=tempfail), defers all mail.
|
||||
mkdir -p /run/selfpost
|
||||
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
|
||||
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
|
||||
# journal-milter — not just probe them at start-up. The sockets are
|
||||
# created by the opendkim and panel users respectively, so bridge them to
|
||||
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
|
||||
# each socket created inside inherits group `selfpost`, and group-traversable
|
||||
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
|
||||
# because signing is strict (default_action=tempfail), rejects all mail.
|
||||
mkdir -p /run/opendkim /run/selfpost
|
||||
chown opendkim:selfpost /run/opendkim
|
||||
chown panel:selfpost /run/selfpost
|
||||
chmod 2750 /run/selfpost
|
||||
# A container that was killed leaves its sockets behind, and a file nobody
|
||||
# listens on would satisfy postfix-wrapper.sh's wait: Postfix would start
|
||||
# before the sender check does and defer mail until it is there. The check's
|
||||
# alive mark goes too: it tells the downtime of the panel restarting inside a
|
||||
# running container, and a restart of the whole container is not the check's
|
||||
# downtime (guide § Health) - a mark left from before would count the whole
|
||||
# outage when supervisord cannot say since when Postfix runs.
|
||||
# (test(1), -S: true for any socket file, whether or not anything listens.)
|
||||
rm -f /run/selfpost/*.sock /run/selfpost/*.alive
|
||||
chmod 2750 /run/opendkim /run/selfpost
|
||||
|
||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||
@@ -225,42 +144,4 @@ rm -f /run/selfpost/*.sock /run/selfpost/*.alive
|
||||
# start the same way the /data normalisation above is.
|
||||
/usr/local/bin/postfix-config.sh
|
||||
|
||||
# The queue tree, on a first start or after a restore, is already built here:
|
||||
# postfix-config.sh ends with "postfix check", whose check-fatal runs
|
||||
# "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
|
||||
# conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
|
||||
# A "postfix set-permissions" for a missing active/ stood here and never ran
|
||||
# (assumptions C-P34).
|
||||
chown -R postfix:postfix /data/postfix/queue
|
||||
# The recursive chown above would leave public/ and maildrop/ in group postfix.
|
||||
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
|
||||
# postdrop and reach the showq socket and the maildrop directory through that
|
||||
# group alone. With the wrong group `postqueue -p` run by the panel answers
|
||||
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
|
||||
# read the queue (the Queue card and the Outbound queue page).
|
||||
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
|
||||
# It would also leave the queue directory itself and pid/ owned by postfix.
|
||||
# Postfix keeps those two owned by root (master, which runs as root, is the only
|
||||
# writer) and logs "warning: not owned by root" for each at every start.
|
||||
# (Postfix 3.10.13, conf/postfix-files:51-68: $queue_directory root 755, maildrop
|
||||
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
|
||||
# directories $mail_owner 700; conf/postfix-script:354-356, "check-warn": "not
|
||||
# owned by root".)
|
||||
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||
|
||||
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the
|
||||
# wrapper's wait for the milter sockets, so its exit at the end of the wait is
|
||||
# one supervisord counts as too quick. A value that is not a number stops the
|
||||
# start here.
|
||||
wait_timeout="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
case "$wait_timeout" in
|
||||
'' | *[!0-9]*)
|
||||
echo "entrypoint: MILTER_WAIT_TIMEOUT must be a whole number of seconds, got '$wait_timeout'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# expr, not $((...)): dash's arithmetic reads "010" as octal and refuses
|
||||
# "08"; expr takes decimal (review of stage 2c, F11).
|
||||
export POSTFIX_STARTSECS=$(expr "$wait_timeout" + 10)
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
|
||||
@@ -1,96 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
|
||||
# recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
||||
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
||||
# panel's log-tailer holds its own descriptor on that inode, so nothing written
|
||||
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
||||
# live container (date not recorded, Postfix 3.7.11), and so in the source:
|
||||
# Postfix 3.10.13, src/util/logwriter.c:99, logwriter_open_or_die(), creates it
|
||||
# with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
|
||||
# otherwise (Postfix 3.9 and later). build/postfix-config.sh now sets that to
|
||||
# 0640, so a file postlogd makes is readable too; logrotate still creates it,
|
||||
# so that the new log is there, with its mode and owner, at once.
|
||||
# The reload: Postfix 3.10.13, conf/postfix-script:535-566, "logrotate" (mv,
|
||||
# then kill -HUP master); master_conf.c:92, master_config() -> :147,
|
||||
# master_restart_service() -> master_status_cleanup(), on which postlogd exits
|
||||
# (src/master/dgram_server.c:241, dgram_server_abort(), :640 on
|
||||
# MASTER_STATUS_FD), and the next postlogd opens the path anew
|
||||
# (src/postlogd/postlogd.c:236, logwriter_open_or_die()).
|
||||
# panel's log-tailer holds its own descriptor on that inode, so nothing
|
||||
# written before the reload is lost. `create` (rather than `nocreate`) matters
|
||||
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
|
||||
# owned by postfix, which the unprivileged panel process cannot read —
|
||||
# confirmed on a live container — so logrotate must be the one to create it.
|
||||
#
|
||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
||||
# (tracked in its state file), so it is safe to invoke this more often than
|
||||
# daily — polling merely bounds how late a legitimate rotation runs. The state
|
||||
# file is beside the log, under /data, not at the default
|
||||
# /var/lib/logrotate/status (logrotate 3.22.0-1: debian/rules:11,
|
||||
# --with-state-file-path) in the container's own filesystem: a recreated
|
||||
# container started without it, logrotate.c:456, newState(), records a log it
|
||||
# has no state for as rotated today, and so a host that recreates the container
|
||||
# daily - an .env change, an image bump - never rotated mail.log at all (audit
|
||||
# 2026-10-12, M19). (logrotate 3.22.0, logrotate.8.in:74: "-s, --state
|
||||
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
|
||||
# root.
|
||||
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||
set -eu
|
||||
|
||||
CONFIG=/etc/logrotate.d/mail
|
||||
STATE=/data/log/.logrotate.status
|
||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||
|
||||
# logrotate refuses configs writable by group or others and goes on without
|
||||
# them — fail here so supervisord reports the fault. (logrotate 3.22.0,
|
||||
# config.c:1099, readConfigFile(): as root, mode & 0022 gives (:1160-1165)
|
||||
# "Ignoring ... because it is writable by group or others." and returns 1; a
|
||||
# config not owned by root is ignored the same way (:1168-1173). Since 3.22.0
|
||||
# that makes logrotate's exit status 1 (logrotate.c:3278-3279 and :3301;
|
||||
# ChangeLog.md, 3.22.0: "in case a configuration file was skipped due to
|
||||
# unsafe permissions the exit status after rotattion will be `1` (#508)");
|
||||
# 3.21.0 returned 0 there and exited 0. So the output is looked at whatever
|
||||
# the status. "Potentially dangerous mode" is only a warning there
|
||||
# (:1154-1157), for any mode & 07533 other than 0400 - this loop chooses to
|
||||
# treat it as fatal too.)
|
||||
logrotate_config_ok() {
|
||||
mode=$(stat -c '%a' "$CONFIG")
|
||||
mode=${mode#0}
|
||||
grp=$(( (mode / 10) % 10 ))
|
||||
oth=$(( mode % 10 ))
|
||||
case $grp in 2|3|6|7) return 1 ;; esac
|
||||
case $oth in 2|3|6|7) return 1 ;; esac
|
||||
return 0
|
||||
}
|
||||
|
||||
logrotate_config_fatal() {
|
||||
echo "logrotate-loop: refusing to run: $CONFIG mode $(stat -c '%a' "$CONFIG") is writable by group or others" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! logrotate_config_ok; then
|
||||
logrotate_config_fatal
|
||||
fi
|
||||
|
||||
run_logrotate() {
|
||||
status=0
|
||||
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
|
||||
case "$out" in
|
||||
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||
echo "$out" >&2
|
||||
echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
while true; do
|
||||
if ! logrotate_config_ok; then
|
||||
logrotate_config_fatal
|
||||
fi
|
||||
if run_logrotate; then
|
||||
if logrotate /etc/logrotate.d/mail; then
|
||||
:
|
||||
else
|
||||
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
||||
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
|
||||
# (logrotate 3.22.0, logrotate.8:402: "create mode owner group" makes the new log
|
||||
# right after the rotation; "delaycompress" leaves the previous log, .1,
|
||||
# uncompressed until the next cycle.)
|
||||
/data/log/mail.log {
|
||||
daily
|
||||
rotate 14
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenDKIM — per-domain signing (spec 6).
|
||||
#
|
||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
||||
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5).
|
||||
#
|
||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
||||
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||
# signs nothing until the first domain is added.
|
||||
|
||||
Syslog no
|
||||
UMask 007
|
||||
Mode s
|
||||
|
||||
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
||||
# local-part for a domain; KeyTable maps each domain to its key and selector.
|
||||
KeyTable /data/opendkim/KeyTable
|
||||
SigningTable refile:/data/opendkim/SigningTable
|
||||
|
||||
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
||||
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
|
||||
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
|
||||
# key-safety check, so the check is disabled here by design.
|
||||
RequireSafeKeys no
|
||||
|
||||
Socket local:/run/opendkim/opendkim.sock
|
||||
PidFile /run/opendkim/opendkim.pid
|
||||
UserID opendkim
|
||||
Background no
|
||||
@@ -1,94 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
|
||||
# audit 2026-10-12, H2).
|
||||
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
||||
# few months; Postfix only re-reads them on reload. A simple daily reload is
|
||||
# more than enough (a day of staleness is harmless) and far simpler than an
|
||||
# inotify watcher — the spec explicitly prefers this.
|
||||
#
|
||||
# Postfix does not read the mounted files: postfix-config.sh copies them at
|
||||
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
|
||||
# root with mode 0600, which a read-only bind mount owned by the host's user
|
||||
# cannot give. A renewal of the mounted files therefore reaches Postfix only
|
||||
# through a new copy. Every interval this compares the mounted files with the
|
||||
# copy; when they differ it copies both beside their targets, checks that the
|
||||
# copies - not the mounted files, which a renewer may be rewriting - are a
|
||||
# certificate and the key that belongs to it, renames both over the targets
|
||||
# and reloads Postfix. When nothing changed it does nothing.
|
||||
# (Postfix 3.10.13: smtpd loads the certificate in pre_jail_init(), once per
|
||||
# process, src/smtpd/smtpd.c:6442; "postfix reload" is SIGHUP to master,
|
||||
# conf/postfix-script:277, after which the old smtpd processes exit on the closed
|
||||
# status pipe, src/master/master_conf.c, master_config() ->
|
||||
# master_restart_service(), and new ones load the files again.)
|
||||
#
|
||||
# Runs under supervisord as root. It sleeps first: at container start the
|
||||
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure
|
||||
# does not trip the crashexit listener; it is logged and tried again.
|
||||
set -u
|
||||
umask 077 # the key's copy is never readable by others, not even for an instant
|
||||
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||
# still bringing Postfix up then) and none until at least one interval has
|
||||
# passed. A reload is harmless when nothing changed.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
||||
SRC_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
SRC_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
DIR=/etc/postfix/tls-internal
|
||||
CERT="$DIR/fullchain.pem"
|
||||
KEY="$DIR/privkey.pem"
|
||||
PENDING=0 # a copy was renewed and Postfix has not been reloaded since
|
||||
|
||||
reload() {
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded ($1)"
|
||||
PENDING=0
|
||||
else
|
||||
echo "cert-reload: postfix reload failed, will try again after ${INTERVAL}s" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# same_pair says whether a certificate file and a private key file belong
|
||||
# together, by comparing the public key each one prints. openssl-x509(1) and
|
||||
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
|
||||
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
|
||||
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
|
||||
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
|
||||
same_pair() {
|
||||
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
|
||||
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
|
||||
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if [ "$PENDING" = 1 ]; then
|
||||
reload "renewed certificate, earlier reload had failed"
|
||||
fi
|
||||
if [ "$(postconf -h smtpd_tls_cert_file 2>/dev/null)" != "$CERT" ]; then
|
||||
# Postfix reads the mounted files themselves (they were missing at
|
||||
# start, so no copy was made): a reload is what picks up a change.
|
||||
reload "periodic TLS refresh"
|
||||
return
|
||||
fi
|
||||
if [ ! -r "$SRC_CERT" ] || [ ! -r "$SRC_KEY" ]; then
|
||||
echo "cert-reload: cannot read ${SRC_CERT} or ${SRC_KEY}; Postfix keeps the certificate it has" >&2
|
||||
return
|
||||
fi
|
||||
if cmp -s "$SRC_CERT" "$CERT" && cmp -s "$SRC_KEY" "$KEY"; then
|
||||
return
|
||||
fi
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
if ! cp "$SRC_CERT" "$CERT.new" || ! cp "$SRC_KEY" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: copying the renewed certificate failed, will try again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
if ! same_pair "$CERT.new" "$KEY.new"; then
|
||||
rm -f "$CERT.new" "$KEY.new"
|
||||
echo "cert-reload: the mounted certificate and key differ from Postfix's copy but do not belong together; will look again after ${INTERVAL}s" >&2
|
||||
return
|
||||
fi
|
||||
chown root:root "$CERT.new" "$KEY.new"
|
||||
chmod 0644 "$CERT.new"
|
||||
chmod 0600 "$KEY.new"
|
||||
mv -f "$KEY.new" "$KEY" && mv -f "$CERT.new" "$CERT"
|
||||
PENDING=1
|
||||
reload "renewed certificate"
|
||||
}
|
||||
|
||||
while true; do
|
||||
sleep "${INTERVAL}"
|
||||
refresh
|
||||
if postfix reload; then
|
||||
echo "cert-reload: postfix reloaded (periodic TLS refresh)"
|
||||
else
|
||||
# Never exit non-zero: a transient reload failure must not trip the
|
||||
# crashexit listener and take the container down. Log and retry next cycle.
|
||||
echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,102 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Generate the outbound-relay Postfix configuration.
|
||||
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
||||
#
|
||||
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
||||
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
|
||||
# re-derived from the environment on each start rather than persisted, so a
|
||||
# fresh, restarted or restored container always ends up with the same config
|
||||
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||
# is generated separately by the panel).
|
||||
# is generated separately by the panel — spec 5.1).
|
||||
#
|
||||
# main.cf is written with `postconf -e`, the master.cf submission services with
|
||||
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
|
||||
# a fixed literal or a container environment variable.
|
||||
# a fixed literal or a container environment variable (spec 7.6.3).
|
||||
set -eu
|
||||
|
||||
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
|
||||
#
|
||||
# A master.cf -o value must not contain whitespace: master splits the line on
|
||||
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
|
||||
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
|
||||
# at start with "fatal: unexpected command-line argument" - port 25 then
|
||||
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
|
||||
# splits a restriction list on commas as well as on whitespace, so every value
|
||||
# passed here is written with commas only; a value with whitespace is a bug in
|
||||
# this script and stops the container.
|
||||
#
|
||||
# Postfix 3.10.13, man/man5/master.5:215-226, "-o name=value (short form)":
|
||||
# "The "short form" does not support whitespace around the "=" or in
|
||||
# parameter values" (the long form "-o { name = value }" would keep it; this
|
||||
# script does not write it). src/postconf/postconf_edit.c:415,
|
||||
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
|
||||
# src/smtpd/smtpd_check.c:664, smtpd_check_parse(), and src/milter/milter.c:646,
|
||||
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
|
||||
#
|
||||
# master.5:41-42, SYNTAX: a line that starts with whitespace continues a
|
||||
# logical line, so the "-o" line awk inserts below the header belongs to the
|
||||
# service. A later postconf -F/-P re-parses a matched entry with its
|
||||
# continuation lines (postconf_edit.c:134, pcf_gobble_cf_line()) and prints
|
||||
# it whole again, keeping these options.
|
||||
master_cf_set_smtp_inet_o() {
|
||||
param="$1"
|
||||
value="$2"
|
||||
case "$value" in
|
||||
*[[:space:]]*)
|
||||
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
awk -v p="$param" -v v="$value" '
|
||||
/^smtp[ \t]+inet[ \t]/ && !done {
|
||||
print
|
||||
print " -o " p "=" v
|
||||
done = 1
|
||||
in_smtp_inet = 1
|
||||
next
|
||||
}
|
||||
in_smtp_inet && /^[ \t]+-o / {
|
||||
line = $0
|
||||
sub(/^[ \t]+-o /, "", line)
|
||||
eq = index(line, "=")
|
||||
on = substr(line, 1, eq - 1)
|
||||
if (on == p) {
|
||||
next
|
||||
}
|
||||
}
|
||||
in_smtp_inet && /^[^ \t#-]/ {
|
||||
in_smtp_inet = 0
|
||||
}
|
||||
{ print }
|
||||
' /etc/postfix/master.cf > /etc/postfix/master.cf.new
|
||||
mv /etc/postfix/master.cf.new /etc/postfix/master.cf
|
||||
}
|
||||
|
||||
# --- environment knobs -------------------------------------------------------
|
||||
# --- environment knobs (spec 8) ----------------------------------------------
|
||||
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
|
||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||
# fails. Fall back to the container hostname only outside a real deployment.
|
||||
# Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:339, xsasl_cyrus_server_create():
|
||||
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
|
||||
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
|
||||
# looked up under serverFQDN.
|
||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
||||
# relay requires TLS on 465; if these files are absent the master still starts
|
||||
# but TLS handshakes on 465 fail until they appear. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
|
||||
# made, and smtpd_proto() then drops each wrapper-mode connection (:5846,
|
||||
# "Wrapper-mode request dropped ... TLS context initialization failed"); every
|
||||
# new smtpd process loads the files again in pre_jail_init().
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||
# master still starts but TLS handshakes on 465 fail until they appear.
|
||||
#
|
||||
# Postfix's TLS_README advises a private key owned by root and readable by no
|
||||
# other user; Postfix does not enforce it (smtpd loads the key as root in
|
||||
# pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
|
||||
# give that mode - it is :ro and owned by the host's user, often readable by
|
||||
# group 10998 for the panel's backup. So the pair is copied into a directory of
|
||||
# Postfix's own, the key root:root 0600, before postconf and `postfix check`
|
||||
# (assumptions D-arch:154).
|
||||
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||
# copy into a writable internal dir and normalise ownership before postconf
|
||||
# and `postfix check`.
|
||||
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||
@@ -113,123 +44,25 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
||||
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||
fi
|
||||
|
||||
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
|
||||
# during IP warm-up.
|
||||
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||
# defaults, sensible during IP warm-up (spec 10).
|
||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
|
||||
# The settings below reach main.cf and master.cf as numbers or time values.
|
||||
# A value that is neither, or one under the parameter's minimum, passes
|
||||
# `postfix check` and kills the daemon that reads it - every smtpd at its
|
||||
# first connection, or anvil and with it the rate limit (audit 2026-10-12,
|
||||
# L31; review of stage 5) - so the start stops here, saying which and why.
|
||||
# Counts and sizes are whole numbers from min up to 2147483647, an int's top
|
||||
# (Postfix 3.10.13 reads the counts with get_mail_conf_int*, src/global/
|
||||
# mail_conf_int.c, check_mail_conf_int(): an int, held to the parameter's own minimum;
|
||||
# message_size_limit is a long, src/global/mail_params.h:2014, and is held to
|
||||
# the int's top here all the same). A time value
|
||||
# is a whole number from 1 with an optional unit, s m h d or w, at most
|
||||
# 2147483647 seconds
|
||||
# (proto/postconf.proto:266-268: "Specify a non-zero time value (an integral value
|
||||
# plus an optional one-letter suffix ...). Time units: s (seconds), m
|
||||
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
|
||||
# 1h, src/global/conv_time.c - so it is refused here).
|
||||
need_number() {
|
||||
case "$2" in
|
||||
'' | *[!0-9]*)
|
||||
echo "postfix-config: $1 must be a whole number, got '$2'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#2}" -gt 10 ] || [ "$2" -lt "$3" ] || [ "$2" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 must be from $3 to 2147483647, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
need_time() {
|
||||
if ! printf '%s' "$2" | grep -Eq '^[1-9][0-9]{0,8}[smhdw]?$'; then
|
||||
echo "postfix-config: $1 must be a time value - a whole number from 1 with an optional s, m, h, d or w - got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
# conv_time multiplies by the unit and refuses what passes an int.
|
||||
n=${2%[smhdw]}
|
||||
case "$2" in
|
||||
*w) n=$((n * 604800)) ;;
|
||||
*d) n=$((n * 86400)) ;;
|
||||
*h) n=$((n * 3600)) ;;
|
||||
*m) n=$((n * 60)) ;;
|
||||
esac
|
||||
if [ "$n" -gt 2147483647 ]; then
|
||||
echo "postfix-config: $1 is more than 2147483647 seconds, got '$2'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
# A rate of 0 is no limit, a size of 0 none (proto/postconf.proto,
|
||||
# smtpd_client_message_rate_limit, message_size_limit); the window is
|
||||
# anvil_rate_time_unit, at least 1 second (src/anvil/anvil.c, main(): its
|
||||
# time table's minimum 1).
|
||||
need_number RATE_LIMIT_MESSAGES_PER_IP "$RATE_MSGS" 0
|
||||
need_number RATE_LIMIT_WINDOW_SECONDS "$RATE_WINDOW" 1
|
||||
need_number INBOUND_RATE_LIMIT_MESSAGES_PER_IP "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number INBOUND_MESSAGE_SIZE_LIMIT "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" 0
|
||||
need_number DMARC_RATE_LIMIT_MESSAGES_PER_IP "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
|
||||
need_number DMARC_MESSAGE_SIZE_LIMIT "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" 0
|
||||
need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
|
||||
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
|
||||
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# Milter sockets, all the panel's: its journal-milter (monitoring,
|
||||
# fail-open), the sender check and the DKIM signer (both strict). Fixed
|
||||
# container paths, matched by postfix-wrapper.sh.
|
||||
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
# The panel's sender check (the From: line against the login, strict).
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
# The panel's DKIM signer (strict): applications' mail, signed with the key
|
||||
# of the login's domain, and the panel's own, with the server's name's
|
||||
# (cmd/panel/signer.go). Matched by postfix-wrapper.sh.
|
||||
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
|
||||
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
|
||||
|
||||
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
||||
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.10.13,
|
||||
# html/DATABASE_README.html:458-464, "texthash": no postmap(1), and the file is read
|
||||
# once by each process - a reload makes the new processes read it again.
|
||||
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||
# Transit mail queue under /data so deferred/active messages survive container
|
||||
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
|
||||
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||
|
||||
# Optional submission service on 587 (off by default, enabled only when a client
|
||||
# library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||
|
||||
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
|
||||
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
|
||||
# absent. When true, smtp inet on 25 accepts only relay_domains + known
|
||||
# recipients (docs/plans/inbound-relay.md).
|
||||
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
||||
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
# The panel's inbound journal milter (monitoring, fail-open), on port 25 only
|
||||
# and only while the inbound relay is on. Matched by postfix-wrapper.sh.
|
||||
INBOUND_JOURNAL_SOCK="${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
||||
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
|
||||
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
|
||||
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
|
||||
|
||||
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
|
||||
# port 25 does not accept report mail and the panel ingest maps stay empty.
|
||||
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
|
||||
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
|
||||
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
|
||||
|
||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
||||
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||
# for messages still marked "queued" survive a container recreate — without
|
||||
@@ -238,74 +71,38 @@ DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_rela
|
||||
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||
|
||||
# /etc/postfix outlives a restart of the container, so a cleanup limit set for
|
||||
# port 25 before is taken back here and set again below when it still applies.
|
||||
postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
|
||||
|
||||
# --- main.cf -----------------------------------------------------------------
|
||||
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||
# inet_protocols=all in a bridge network without IPv6: Postfix 3.10.13,
|
||||
# src/util/inet_proto.c:200, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
|
||||
# succeeds, and src/smtp/smtp_connect.c:1102-1142, smtp_connect_inet(), goes on
|
||||
# to the next address when a connection fails - an AAAA that cannot be reached
|
||||
# costs one failed attempt, then IPv4. Postfix
|
||||
# 3.10.13, src/global/mail_params.h:4406, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
|
||||
# src/global/maillog_client.c:232, maillog_client_init(): fatal "does not match any
|
||||
# prefix", in every Postfix program that logs, postfix(1) itself included.
|
||||
#
|
||||
# maillog_file_permissions=0640: the log is the panel's to read, through
|
||||
# group selfpost (the setgid /data/log, build/entrypoint.sh). entrypoint.sh
|
||||
# and logrotate's "create" make it 0640 themselves; this is for a file that
|
||||
# Postfix makes - one removed while the container runs - which would
|
||||
# otherwise be 0600 and unreadable to the panel. (Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4418, DEF_MAILLOG_FILE_PERMS "0600";
|
||||
# src/global/mail_params.c:997, set_logwriter_create_perms(); src/util/
|
||||
# logwriter.c:143-148: "644", "640" or "600", leading zeros ignored, anything
|
||||
# else a warning and 0600; :99, logwriter_open_or_die() creates with it;
|
||||
# Postfix 3.9 and later, proto/postconf.proto:18500-18509.)
|
||||
postconf -e \
|
||||
"myhostname=${HOSTNAME_VALUE}" \
|
||||
"maillog_file=${MAIL_LOG_PATH}" \
|
||||
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||
"maillog_file_permissions=0640" \
|
||||
"mydestination=" \
|
||||
"relayhost=" \
|
||||
"inet_interfaces=all" \
|
||||
"inet_protocols=all"
|
||||
|
||||
# postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
|
||||
# html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
|
||||
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
|
||||
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||
# image cannot lose the service.
|
||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||
|
||||
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||
# these so a misfiled recipient never gets delivered locally. An empty
|
||||
# mydestination is not enough: an address literal of an interface the
|
||||
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.10.13,
|
||||
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
|
||||
# itself is replaced by a refusal, as Postfix's null-client example does
|
||||
# (README_FILES/STANDARD_CONFIGURATION_README:209, "local_transport = error:local
|
||||
# mail delivery is disabled"; assumptions C-P8).
|
||||
# these so a misfiled recipient never gets delivered locally.
|
||||
postconf -e \
|
||||
"local_recipient_maps=" \
|
||||
"alias_maps=" \
|
||||
"alias_database=" \
|
||||
"local_transport=error:local mail delivery is disabled"
|
||||
"alias_database="
|
||||
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
||||
postconf -e \
|
||||
"smtp_tls_security_level=may" \
|
||||
"smtp_tls_note_starttls_offer=yes"
|
||||
|
||||
# TLS server material shared by every inbound service. auth_only guarantees
|
||||
# credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_sasl_proto.c:188, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
|
||||
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c:6546-6547,
|
||||
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
|
||||
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
||||
# guarantees credentials are never accepted before TLS is up on any port.
|
||||
postconf -e \
|
||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||
@@ -313,30 +110,11 @@ postconf -e \
|
||||
"smtpd_tls_auth_only=yes" \
|
||||
"smtpd_tls_loglevel=1"
|
||||
|
||||
# TLS 1.2 at least, opportunistic or required - and the client's too where an
|
||||
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
|
||||
# the security review of stage 5) (audit 2026-10-12, L31; Postfix
|
||||
# 3.10.13, proto/postconf.proto:13240-13250, smtpd_tls_protocols: since 3.6 the
|
||||
# default is ">=TLSv1", and a lower bound is written ">=version"). A sender that
|
||||
# offers less on port 25 sends in the clear, as one without TLS does. VRFY
|
||||
# answers no question about who is accepted on port 25 (relay_recipient_maps),
|
||||
# and the banner names no distribution (Debian's adds "(Debian)": postfix
|
||||
# 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
|
||||
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
|
||||
postconf -e \
|
||||
"smtpd_tls_protocols=>=TLSv1.2" \
|
||||
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"disable_vrfy_command=yes" \
|
||||
'smtpd_banner=$myhostname ESMTP'
|
||||
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
||||
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
||||
# uses for sender_login_maps is the BARE login the panel writes into the map;
|
||||
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||
# the server realm, which equals the realm the accounts were created under.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_sasl_glue.c:249, smtpd_sasl_activate(): an empty
|
||||
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
|
||||
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
|
||||
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
|
||||
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
|
||||
# as the server realm, which equals the realm the accounts were created under.
|
||||
postconf -e \
|
||||
"smtpd_sasl_auth_enable=yes" \
|
||||
"smtpd_sasl_type=cyrus" \
|
||||
@@ -346,162 +124,68 @@ postconf -e \
|
||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||
"broken_sasl_auth_clients=yes"
|
||||
|
||||
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
|
||||
# up in this map with mail_addr_find(), the order being smtpd's and not
|
||||
# texthash's: the full address, the address without its extension, then the
|
||||
# "@domain" wildcard - so both address modes work from the same map (Postfix
|
||||
# 3.10.13, src/smtpd/smtpd_check.c:3933, reject_auth_sender_login_mismatch();
|
||||
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
|
||||
#
|
||||
# recipient_delimiter is said here and not left to the distribution's main.cf:
|
||||
# it is what lets an application listed as invoices@ send as invoices+march@,
|
||||
# and the panel's sender check makes the same second try for the From: line
|
||||
# (extensionDelimiter in internal/milter/sender.go) - the two must agree.
|
||||
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
|
||||
# strip_addr_internal(), for its second try.)
|
||||
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
|
||||
# resolves the full address first, then the "@domain" wildcard, so both address
|
||||
# modes work from the same map.
|
||||
postconf -e \
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||
"recipient_delimiter=+" \
|
||||
"queue_directory=${QUEUE_DIR}"
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
||||
|
||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||
# and every authenticated sender address must be owned by its login. NO
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||
# network. This is what makes an open relay impossible. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd_check.c:5076, smtpd_check_rcpt(), :5160-5173: the relay and
|
||||
# recipient lists are evaluated in turn and the first REJECT ends them.)
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by network
|
||||
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
||||
postconf -e \
|
||||
"smtpd_helo_required=yes" \
|
||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||
|
||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
||||
# journal-milter (level 2) is down. Postfix 3.10.13, src/smtpd/smtpd.c:2566,
|
||||
# mail_cmd(), :2624: "450 4.7.1 Error: too much mail from", except for clients
|
||||
# in smtpd_client_event_limit_exceptions (:2619, hogger_list; default
|
||||
# $mynetworks, src/smtpd/smtpd.c:779).
|
||||
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
|
||||
# "Client IP" is the address the container sees: an IPv4 client's own, but
|
||||
# for a client over IPv6 the compose network's gateway, through Docker's
|
||||
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
|
||||
# C-K5; guide, "Rate limiting - level 1").
|
||||
#
|
||||
# mynetworks is said here, loopback only, and not left to the package: it is
|
||||
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
|
||||
# spare. The value is the one Debian's debconf default gives (postfix
|
||||
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
|
||||
# by debian/postfix.postinst:249-262 on a new main.cf, where :180,
|
||||
# fset_all_changed, marks every debconf value to be written; main.cf.in:28 has 127.0.0.0/8
|
||||
# alone), so on this image it changes nothing.
|
||||
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
||||
# if the journal-milter (level 2) is down.
|
||||
postconf -e \
|
||||
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
# Milter chain. The sender check comes first and is strict
|
||||
# (default_action=tempfail): it holds the From: line against the login, the
|
||||
# address a receiver holds the signature to, so a message it could not judge
|
||||
# must not be signed. While the panel does not answer on that socket, mail is
|
||||
# deferred (docs/security.md).
|
||||
#
|
||||
# The panel's DKIM signer follows, strict too: unreachable, it defers rather
|
||||
# than send unsigned (docs/plans/panel-dkim-signing.md). It signs with the
|
||||
# key of the login's domain. The journal-milter is monitoring only and is
|
||||
# fail-open (default_action=accept): its failure must never block the relay.
|
||||
# Per-milter settings use Postfix 3.0+ brace syntax.
|
||||
#
|
||||
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
|
||||
# the panel's own notifications, sent as postmaster@ the hostname. The
|
||||
# signer's own socket for it signs them with the key of the server's own
|
||||
# name; the journal-milter is not on this chain, since they are not an
|
||||
# application's mail. Bounces Postfix
|
||||
# generates itself do not pass it (internal_mail_filter_classes is empty).
|
||||
#
|
||||
# Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
|
||||
# Milter applications" (the braces, Postfix 3.0 and later) and :449, "Signing
|
||||
# internally-generated bounce messages"; src/milter/milter.c:399-564,
|
||||
# milter_*_event() and milter_message(): milters are asked in list order and
|
||||
# the first non-empty reply ends the round; src/global/mail_params.h:3599,
|
||||
# DEF_INT_FILT_CLASSES INT_FILT_CLASS_NONE.
|
||||
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
|
||||
# (default_action=tempfail: if it is unreachable, defer rather than send
|
||||
# unsigned). The journal-milter is monitoring only and is fail-open
|
||||
# (default_action=accept): its failure must never block the relay. Per-milter
|
||||
# settings use Postfix 3.0+ brace syntax.
|
||||
postconf -e \
|
||||
"milter_protocol=6" \
|
||||
"milter_default_action=tempfail" \
|
||||
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }"
|
||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters="
|
||||
|
||||
# A signed message is delivered as it was signed: Postfix's SMTP client would
|
||||
# otherwise rewrite an 8-bit message as quoted-printable for a server that
|
||||
# does not announce 8BITMIME, and every body hash over it would fail.
|
||||
# Postfix 3.10.13, src/global/mail_params.h:3088, DEF_DISABLE_MIME_OCONV 0
|
||||
# (the conversion is on by default); proto/postconf.proto:7992,
|
||||
# disable_mime_output_conversion: "Disable the conversion of 8BITMIME format
|
||||
# to 7BIT format". Not set until 2.8.0 - OpenDKIM's signatures, before the
|
||||
# panel's, were exposed to it as well.
|
||||
postconf -e "disable_mime_output_conversion=yes"
|
||||
|
||||
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
|
||||
# address. The panel's milters judge every message by that address (an
|
||||
# application's client-address list) and record it (the inbound journal), and
|
||||
# the address given once at connect is not there for a connection's later
|
||||
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
|
||||
# 3.10.13, src/global/mail_params.h:3532-3534, DEF_MILT_MAIL_MACROS.
|
||||
postconf -e \
|
||||
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
|
||||
|
||||
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
||||
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||
# defaults (300s content) elapse. With default_action per milter, a
|
||||
# journal-milter hang then resolves to accept and a signer hang to tempfail,
|
||||
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
|
||||
# replies) must fail open just like a crash, not stall mail acceptance until the
|
||||
# Postfix defaults (300s content) elapse. With default_action per milter, a
|
||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||
# but within seconds rather than minutes. Values are well above any healthy
|
||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||
# operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
|
||||
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
|
||||
# operation.
|
||||
postconf -e \
|
||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# --- master.cf: inbound submission services ----------------------------------
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
||||
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# and the Cyrus config outside any chroot.
|
||||
# The names smtps, submission and smtp need no /etc/services: Postfix 3.10.13,
|
||||
# src/global/mail_params.h:4450-4451, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
|
||||
# smtps=submissions=465, submission=587" (src/util/find_inet.c:94-99,
|
||||
# find_inet_port() via filter_known_tcp_port()).
|
||||
#
|
||||
# smtpd_hide_client_session=yes on both submission services (owner's
|
||||
# decision, 2026-10-15): the Received: header Postfix adds names neither the
|
||||
# application's HELO name, reverse name and address, nor its TLS session,
|
||||
# SASL login or the protocol - only "Received: by <myhostname> (Postfix) id
|
||||
# <queue id>", "for <rcpt>" when there is one recipient, and the date
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd.c:3495, the client part written only
|
||||
# when the parameter is off; :3627-3645, the "by" line opening the header
|
||||
# when it is on). proto/postconf.proto:19580-19594: "may be used for the
|
||||
# port 587 and 465 MUA services"; port 25 must keep the default, RFC 5321's
|
||||
# trace, and does (smtp/inet below sets nothing). The queue id stays in the
|
||||
# header, so a complaint about a message still leads to its row in the
|
||||
# panel's log and to smtpd's "client=" line in mail.log.
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"smtps/inet/smtpd_hide_client_session=yes" \
|
||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
|
||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
||||
# after disabling it does not leave the port listening. (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:6525-6526, pre_jail_init(): smtpd_tls_security_level=encrypt
|
||||
# sets enforce_tls, which implies smtpd_tls_auth_only (:6547);
|
||||
# src/postconf/postconf_edit.c:319, pcf_edit_master(): -MX drops the matched
|
||||
# entry, continuation lines included.)
|
||||
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
|
||||
# the only difference is TLS is negotiated via STARTTLS, so require encryption
|
||||
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
|
||||
# restart after disabling it does not leave the port listening.
|
||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"submission/inet/smtpd_hide_client_session=yes" \
|
||||
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
@@ -509,298 +193,19 @@ else
|
||||
postconf -MX "submission/inet" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||
# Debian's stock master.cf enables smtp/inet (postfix 3.10.13-0+deb13u1,
|
||||
# debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
|
||||
# uncommented; conf/master.cf:12). When both flags are off, remove
|
||||
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||
# smtp/unix). When either is on: no SASL, no signing; relay accepts only
|
||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_DOMAINS_SETTING=""
|
||||
TRANSPORT_SETTING=""
|
||||
RECIPIENT_RESTRICTIONS="reject"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||
RECIPIENT_RESTRICTIONS="reject_unauth_destination,reject_unlisted_recipient"
|
||||
fi
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
|
||||
# flags=, user= and argv= are arguments of pipe(8), not -o parameters:
|
||||
# postconf -P refuses them ("unmatched service_name/type") and leaves a
|
||||
# bare "pipe", which dies with "fatal: missing user= command-line
|
||||
# attribute" on every report. They go into the service line itself.
|
||||
# (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
|
||||
# Postfix 3.10.13: src/postconf/postconf_master.c:1029,
|
||||
# pcf_edit_master_param(), -P writes "-o name=value" and nothing else;
|
||||
# postconf_edit.c:617 warns "unmatched service_name/type" for a service
|
||||
# master.cf does not have; src/pipe/pipe.c:867, get_service_attr(),
|
||||
# :1018: "missing user= command-line attribute".)
|
||||
# ${recipient} is expanded by pipe(8), not by the shell: the command
|
||||
# learns which report address the message came to from its argument
|
||||
# (man/man8/pipe.8:289, ${recipient}: as many arguments as recipients).
|
||||
# One at a time (the 1 before "pipe"): reports are in no hurry, every run
|
||||
# writes to the database the panel writes to, and a hundred of them in
|
||||
# parallel - Postfix's default - is what a flood of reports would start.
|
||||
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
|
||||
# One recipient per run, so ${recipient} is one argument.
|
||||
postconf -e "dmarc-ingest_destination_recipient_limit=1"
|
||||
# pipe(8) starts the command with export_environment only, so what the
|
||||
# command reads from the container's environment is named there.
|
||||
# export_environment is a list split at whitespace in which Postfix
|
||||
# expands "$": a path with either would reach the command as another
|
||||
# path, and it would quietly open a database of its own. (Postfix 3.10.13,
|
||||
# man/man8/pipe.8:417, export_environment; src/global/mail_params.h:2650,
|
||||
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/util/clean_env.c. The
|
||||
# parameter is main.cf's, so every Postfix process exports these too.)
|
||||
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
|
||||
*[[:space:]]* | *'$'*)
|
||||
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
|
||||
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
|
||||
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
|
||||
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
|
||||
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||
else
|
||||
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||
fi
|
||||
# Comma, not a space, between check_recipient_access and its table: see
|
||||
# master_cf_set_smtp_inet_o (master.5, "-o name=value (short form)": "use
|
||||
# commas instead of spaces if the parameter supports that").
|
||||
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||
else
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
|
||||
fi
|
||||
postconf -e \
|
||||
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||
"transport_maps=${TRANSPORT_SETTING}" \
|
||||
"smtpd_reject_unlisted_recipient=yes"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
# With the relay on, Postfix refuses every recipient of a relay domain
|
||||
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
|
||||
# above) - and it does so after the restriction list, whatever
|
||||
# check_recipient_access answered. The DMARC report addresses live on a
|
||||
# relay domain too (the server's own name), so their table is a recipient
|
||||
# table as well; without it they are "User unknown in relay recipient
|
||||
# table" and no report is ever accepted while both features are on.
|
||||
# (Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(),
|
||||
# :5196-5200: after the lists, check_recipient_rcpt_maps() runs unless
|
||||
# the status is REJECT - an OK from check_recipient_access does not
|
||||
# skip it.)
|
||||
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||
fi
|
||||
# The panel writes the same "[host]:port" into transport and
|
||||
# tls_policy: the policy lookup key is the verbatim next-hop, port and
|
||||
# brackets included (Postfix 3.10.13, README_FILES/TLS_README:914, "TLS
|
||||
# policy table" note: ":smtp", ":25" and no port are different keys).
|
||||
postconf -e \
|
||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||
else
|
||||
postconf -e \
|
||||
"relay_recipient_maps=" \
|
||||
"smtp_tls_policy_maps="
|
||||
fi
|
||||
|
||||
# Milters on port 25. main.cf's chain (sender check and signer strict +
|
||||
# journal-milter) is for the SUBMISSION path only: the signer must not gate someone else's
|
||||
# inbound mail (a signing outage would defer it), and the journal-milter
|
||||
# would file inbound mail into the outbound send log under the *sender's*
|
||||
# domain — polluting Deliveries and letting a forged From: consume that
|
||||
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
||||
# here: the optional antispam milter, then - with the inbound relay on - the
|
||||
# panel's inbound journal milter, or nothing at all. (Postfix 3.10.13,
|
||||
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
|
||||
# src/smtpd/smtpd.c:3475 hands its own milter list to cleanup, milter_send(),
|
||||
# and src/cleanup/cleanup_milter.c:1997, cleanup_milter_receive(), uses it.)
|
||||
INBOUND_MILTERS=""
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||
# A glob's "*" matches anything, so the shape alone would pass
|
||||
# "inet:a:1},{unix:/elsewhere": characters outside the set are refused
|
||||
# first, then the shape is checked.
|
||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||
*[!A-Za-z0-9._:/-]*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
exit 1
|
||||
;;
|
||||
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
|
||||
accept|tempfail) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
# Last in the chain: Postfix hands a message to its milters one after
|
||||
# another at end-of-message, so this one reads the verdict headers the
|
||||
# filter added and sees only what the filter let through. It watches and
|
||||
# never refuses, and must never hold up someone else's mail: accept when
|
||||
# it is down. A comma, not a space, between the two - see
|
||||
# master_cf_set_smtp_inet_o.
|
||||
# Postfix 3.10.13: src/milter/milter.c:564, milter_message(), sends the
|
||||
# message to each milter in list order; src/milter/milter8.c:960,
|
||||
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER,
|
||||
# :1424) to the queue file at once, and the next milter8_message()
|
||||
# (:2544) reads that file with rec_get() (:2612), which follows the
|
||||
# added records.
|
||||
case "${INBOUND_JOURNAL_SOCK}" in
|
||||
*[!A-Za-z0-9._/-]*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
/[A-Za-z0-9._/-]*) ;;
|
||||
*)
|
||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
INBOUND_MILTERS="${INBOUND_MILTERS:+${INBOUND_MILTERS},}{unix:${INBOUND_JOURNAL_SOCK},default_action=accept}"
|
||||
fi
|
||||
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
else
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
|
||||
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
|
||||
# into the inbound journal and has to tell the two apart. (Postfix 3.10.13,
|
||||
# src/global/mail_task.c:56, mail_task(): the log tag is syslog_name "/"
|
||||
# program name. Only smtpd carries it: cleanup and the rest are other
|
||||
# services and log as postfix/.)
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||
# smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence. normalize
|
||||
# takes a bare <LF> as a line end but ends DATA only at the standard
|
||||
# <CR><LF>.<CR><LF>: a "." line that a bare <LF> precedes or ends is
|
||||
# dropped, not taken as the end of the message (Postfix 3.10.13,
|
||||
# src/smtpd/smtpd.c:1693-1694, bare_lf_mask_table, normalize =
|
||||
# BARE_LF_FLAG_WANT_STD_EOD; :3712-3716, the DATA loop; RELEASE_NOTES-3.9:
|
||||
# 166-167, "the recommended setting "smtpd_forbid_bare_newline
|
||||
# = normalize""). Since 3.9 normalize is also the default (src/smtpd/
|
||||
# smtpd.c:830, "Postfix >= 3.9: normalize"; src/global/mail_params.h:4313,
|
||||
# DEF_SMTPD_FORBID_BARE_LF "normalize"); the line stays so that port 25
|
||||
# does not depend on a default (it came in 2.6.1, while the image's
|
||||
# Postfix 3.7.11 defaulted to "no").
|
||||
# Its exclusions default to $mynetworks (mail_params.h:4316), loopback
|
||||
# only here (above), so no client is spared. The submission ports take
|
||||
# the default, normalize as well since this Postfix: an application whose
|
||||
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
|
||||
# end there (own-mta-builds, § 5: their smuggled transaction would still
|
||||
# be the authenticated session's, so they were left to the default).
|
||||
postconf -P \
|
||||
"smtp/inet/smtpd_forbid_bare_newline=normalize" \
|
||||
"smtp/inet/syslog_name=postfix/inbound" \
|
||||
"smtp/inet/smtpd_sasl_auth_enable=no" \
|
||||
"smtp/inet/smtpd_tls_auth_only=no" \
|
||||
"smtp/inet/smtpd_sender_login_maps=" \
|
||||
"smtp/inet/smtpd_sender_restrictions=" \
|
||||
"smtp/inet/smtpd_client_restrictions=" \
|
||||
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
|
||||
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
|
||||
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||
master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
|
||||
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
|
||||
|
||||
# smtpd's -o limit is not the whole of it: cleanup writes the queue file
|
||||
# under its own message_size_limit, main.cf's, which is never set here and
|
||||
# so is 10240000 (Postfix 3.10.13, src/cleanup/cleanup_init.c:444,
|
||||
# cleanup_post_jail(), :455, set_file_limit(); src/global/mail_params.h:2013,
|
||||
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
|
||||
# (assumptions C-P31). cleanup gets port 25's limit when it is larger;
|
||||
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c:3721,
|
||||
# :3964, :4136, :4250, the DATA and BDAT size checks).
|
||||
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
|
||||
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
|
||||
fi
|
||||
else
|
||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
||||
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
|
||||
postconf -e \
|
||||
"relay_domains=" \
|
||||
"transport_maps=" \
|
||||
"relay_recipient_maps=" \
|
||||
"smtp_tls_policy_maps="
|
||||
fi
|
||||
|
||||
# Disable chroot for every service. Debian ships the smtp delivery agent and
|
||||
# others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf
|
||||
# — so outbound MX lookups fail with "Host not found" and mail never leaves.
|
||||
# Inside a container the chroot buys little (the container is the isolation
|
||||
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
|
||||
# own smtps/submission services are already n; this covers the delivery agents
|
||||
# and the rest. (postfix 3.10.13-0+deb13u1: debian/patches/40_chroot_by_default.diff
|
||||
# sets chroot y. chroot(2) also needs CAP_SYS_CHROOT, which
|
||||
# deploy/docker-compose.yml drops with cap_drop: ALL - capabilities(7).)
|
||||
#
|
||||
# Debian's chroot setup now runs on every "postfix check" and "postfix
|
||||
# start-fg": conf/post-install:587 (debian/patches/
|
||||
# debian-run-configure-instance-from-create-missing.patch) calls
|
||||
# /usr/lib/postfix/configure-instance.sh at the end of create-missing, and
|
||||
# that copies etc/localtime, etc/resolv.conf, etc/hosts and the like into
|
||||
# $queue_directory/etc - here /data/postfix/queue/etc - whether a service is
|
||||
# chrooted or not (debian/configure-instance.in:80-91, before its
|
||||
# need_chroot test at :93). With nothing chrooted those copies are read by
|
||||
# nobody, so the image turns the sync off with SYNC_CHROOT= in
|
||||
# /etc/default/postfix (build/Dockerfile; configure-instance.in:22-24, :44,
|
||||
# "[ -n "$SYNC_CHROOT" ] || exit 0"; debian/README.Debian:136-138).
|
||||
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
||||
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
||||
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||
# never leaves. Inside a container the chroot buys little (the container is the
|
||||
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
|
||||
# uniformly. Our own smtps/submission services are already n; this covers the
|
||||
# delivery agents and the rest.
|
||||
postconf -F "*/*/chroot=n"
|
||||
|
||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
|
||||
# only — both are safe because TLS is mandatory before auth on every port.
|
||||
# The directory is said here, cyrus_sasl_config_path, and not left to the
|
||||
# package's main.cf. Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:227-232,
|
||||
# xsasl_cyrus_server_init(): a non-empty cyrus_sasl_config_path goes to Cyrus
|
||||
# with sasl_set_path(SASL_PATH_TYPE_CONFIG, ...), and :246,
|
||||
# sasl_server_init(callbacks, path_info) with smtpd_sasl_path=smtpd
|
||||
# (src/smtpd/smtpd_sasl_glue.c:188-189) names smtpd.conf there; the default
|
||||
# is empty (src/global/mail_params.h:1690, DEF_CYRUS_CONF_PATH ""), and then
|
||||
# Cyrus looks in its own path, /etc/sasl2:/etc/sasl:/usr/lib/<triplet>/sasl2:
|
||||
# /usr/lib/sasl2 (cyrus-sasl2 2.1.28+dfsg1, debian/rules:96, --with-configdir;
|
||||
# lib/common.c:210, sasl_set_path()), which this file is not in. Bookworm's
|
||||
# package found it with a patch (07_sasl_config.diff, gone in 3.10.13); trixie's
|
||||
# writes cyrus_sasl_config_path = /etc/postfix/sasl into a new main.cf
|
||||
# (postfix 3.10.13-0+deb13u1, debian/main.cf.in:71) and into an upgraded one
|
||||
# only when it is empty (debian/postfix.postinst:88-97). The keys: Cyrus SASL
|
||||
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
|
||||
# sasldb_path, mech_list).
|
||||
postconf -e "cyrus_sasl_config_path=/etc/postfix/sasl"
|
||||
mkdir -p /etc/postfix/sasl
|
||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||
pwcheck_method: auxprop
|
||||
@@ -810,11 +215,7 @@ mech_list: PLAIN LOGIN
|
||||
EOF
|
||||
|
||||
# Validate the generated configuration; fail loudly if postconf produced
|
||||
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
|
||||
# 3.10.13, conf/postfix-script:292, "check": "check-fatal || exit 1"; check-fatal
|
||||
# also runs "post-install create-missing" (:314), which builds the queue tree
|
||||
# and, in Debian's package, runs configure-instance.sh - see the chroot note
|
||||
# above.)
|
||||
# anything Postfix rejects, before the wrapper tries to start it.
|
||||
set +e
|
||||
check_out=$(postfix check 2>&1)
|
||||
ec=$?
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The reload the panel asks supervisord for (program postfix-reload,
|
||||
# build/supervisord.conf; internal/supervisor RunOnce reads its exit status).
|
||||
#
|
||||
# Exit 75 says no Postfix master runs - it is stopped, or still in its start
|
||||
# behind the milter wait (build/postfix-wrapper.sh) - and the master that
|
||||
# starts next reads the files as they are now, so the panel takes that as
|
||||
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
|
||||
# 3.10.13, conf/postfix-script:266-279, "reload": exit 1 when not running or
|
||||
# when postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
|
||||
# -t" exits 0, silently, while no master holds the lock (src/master/master.c:495-499,
|
||||
# test_lock). A master that stops between the two is a failure the panel
|
||||
# reports - the rarer mistake (review of stage 2c, F5). Since Postfix 3.8 the
|
||||
# reload also writes "refreshing the Postfix mail system" to stderr, which
|
||||
# goes to the container's log (postfix-wrapper.sh, the note on 3.8's stderr
|
||||
# logging); the panel reads only the exit status.
|
||||
d=$(postconf -h daemon_directory) || exit 1
|
||||
if "$d/master" -t 2>/dev/null; then
|
||||
echo "postfix-reload: Postfix is not running; its start reads the files" >&2
|
||||
exit 75
|
||||
fi
|
||||
exec /usr/sbin/postfix reload
|
||||
@@ -1,39 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
||||
# reach the milter sockets before they are listening.
|
||||
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
||||
# try to reach the milter sockets before they are listening.
|
||||
#
|
||||
# It blocks until the milter sockets — the panel's sender check, DKIM signer
|
||||
# (two) and journal-milter and, with the inbound relay on, its
|
||||
# inbound journal milter —
|
||||
# are present, then runs `postfix start-fg` and stays its parent. If they are
|
||||
# not ready within the timeout it exits non-zero WITHOUT starting Postfix.
|
||||
# supervisord counts that exit as one that came too quickly - the program's
|
||||
# startsecs is the timeout plus ten seconds (build/entrypoint.sh) - so after
|
||||
# startretries it is FATAL and crashexit takes the container down, instead of
|
||||
# a relay running blind (supervisor 4.2.5, supervisor/process.py,
|
||||
# Subprocess.finish(): an exit before startsecs is "Exited too quickly" and
|
||||
# goes to BACKOFF; transition(): BACKOFF past startretries is FATAL;
|
||||
# assumptions C-V6).
|
||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||
# an explicit startup failure instead of a relay running blind.
|
||||
#
|
||||
# This handles cold start only. Once Postfix runs, a milter that does not
|
||||
# answer costs what its default_action says (build/postfix-config.sh): mail is
|
||||
# deferred for the signer and the sender check, and passes the journal-milter.
|
||||
# This handles cold start only. Runtime milter failures after a successful start
|
||||
# are governed by fail-open (milter_default_action).
|
||||
set -eu
|
||||
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
|
||||
SIGN_SOCK="${DKIM_SIGN_SOCKET:-/run/selfpost/sign.sock}"
|
||||
SIGN_LOCAL_SOCK="${DKIM_SIGN_LOCAL_SOCKET:-/run/selfpost/sign-local.sock}"
|
||||
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
INTERVAL=1
|
||||
|
||||
SOCKS="$JOURNAL_SOCK $SENDER_SOCK $SIGN_SOCK $SIGN_LOCAL_SOCK"
|
||||
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
|
||||
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
|
||||
fi
|
||||
|
||||
elapsed=0
|
||||
for sock in $SOCKS; do
|
||||
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
while [ ! -S "$sock" ]; do
|
||||
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
||||
@@ -45,68 +29,5 @@ for sock in $SOCKS; do
|
||||
echo "postfix-wrapper: milter socket ready: $sock"
|
||||
done
|
||||
|
||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
||||
# Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
|
||||
# whose "start-fg" (:200-210) execs master only as PID 1 and otherwise runs
|
||||
# "master -s" as its child; master then makes itself a session leader
|
||||
# (src/master/master.c:441, setsid()). A signal supervisord sends this
|
||||
# program never reaches master
|
||||
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
|
||||
# alone without stopasgroup, and master is not in its group anyway): before,
|
||||
# a stop left master running - a restart of the program then found Postfix
|
||||
# "already running", and a stop of the container cut Postfix off with
|
||||
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
|
||||
# "postfix stop" (conf/postfix-script:237-253, "stop": TERM to master, then
|
||||
# waits up to five seconds and kills its group; src/master/master_sig.c:205,
|
||||
# master_sigdeath(): "terminating on signal 15"). Debian's redirect of
|
||||
# "postfix start" to systemd (debian/patches/
|
||||
# debian-re-run-startup-through-systemd.patch, conf/postfix-script:170-189)
|
||||
# leaves start-fg alone and needs /run/systemd/system, which this container
|
||||
# has not.
|
||||
#
|
||||
# Since Postfix 3.8 postfix(1) and postlog(1) - postfix-script's $INFO, $WARN
|
||||
# and $FATAL - write their messages to stderr even when it is not a terminal
|
||||
# (src/postfix/postfix.c:546, msg_vstream_init() no longer under isatty();
|
||||
# RELEASE_NOTES-3.8:114-115, [Incompat 20230308]), besides maillog_file. So
|
||||
# "starting the Postfix mail system", "stopping ..." and check-warn's
|
||||
# warnings now reach the container's log through this program's stderr
|
||||
# (build/supervisord.conf) as well as /data/log/mail.log; nothing reads them
|
||||
# there but a person.
|
||||
#
|
||||
# "postfix start-fg" spends its first seconds in its checks before master
|
||||
# runs; a stop asked then would find nothing to stop, so it is asked again
|
||||
# until the child is gone. "master -t" exits 0, silently, while no master
|
||||
# holds the lock (src/master/master.c:495-499, test_lock).
|
||||
daemon_directory=$(postconf -h daemon_directory)
|
||||
child=
|
||||
stop_asked=
|
||||
stop_postfix() {
|
||||
if [ -z "$child" ]; then
|
||||
# Between "&" and "child=$!": stopped right after the pid is known.
|
||||
stop_asked=1
|
||||
return
|
||||
fi
|
||||
trap '' TERM INT
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
if "$daemon_directory/master" -t 2>/dev/null; then
|
||||
sleep 1
|
||||
else
|
||||
postfix stop || true
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
trap stop_postfix TERM INT
|
||||
postfix start-fg &
|
||||
child=$!
|
||||
if [ -n "$stop_asked" ]; then
|
||||
stop_postfix
|
||||
fi
|
||||
# A trapped signal makes wait return at once, the trap is taken right after
|
||||
# (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits.
|
||||
# Otherwise wait returns when Postfix ended on its own: its status is this
|
||||
# program's, and supervisord restarts it.
|
||||
status=0
|
||||
wait "$child" || status=$?
|
||||
echo "postfix-wrapper: postfix exited with status $status" >&2
|
||||
exit "$status"
|
||||
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||
exec postfix start-fg
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
; SelfPost process supervision.
|
||||
; SelfPost process supervision (spec 4).
|
||||
;
|
||||
; Start ORDER is enforced by priority=: the panel (which opens the sender
|
||||
; check's, the DKIM signer's and the journal-milter's sockets), then the
|
||||
; Postfix wrapper — which additionally blocks until the milter sockets are ready
|
||||
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
|
||||
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
|
||||
; supervisord.py, runforever(), transitions every group in that order in one
|
||||
; pass, with no wait for a program to be ready.)
|
||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||
; blocks until both milter sockets are ready before starting Postfix.
|
||||
;
|
||||
; If a managed process of the mail path exhausts its restart retries (FATAL),
|
||||
; or exits unexpectedly five times in ten minutes, the crashexit event listener
|
||||
; brings the whole container down so Docker's restart policy starts it again
|
||||
; (the same container: docs.docker.com, "Start containers automatically",
|
||||
; restart policies), rather than lingering with a dead component. A program
|
||||
; reaches FATAL only through BACKOFF, by exits that come before its startsecs
|
||||
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
|
||||
; transition()): an exit after it is EXITED and simply restarted, for ever -
|
||||
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
|
||||
; postfix-reload's and cert-reload's failures are left on Server > Health
|
||||
; (build/crashexit.py).
|
||||
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||
; event listener brings the whole container down so Docker's restart policy can
|
||||
; recreate it cleanly, rather than lingering with a dead component.
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
@@ -28,19 +16,15 @@ logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
; The control socket is reachable by the `supervisor` group, whose one member
|
||||
; is the panel (the unprivileged `panel` user): it asks supervisord to run the
|
||||
; Postfix reload, and for the processes' state. Whoever reaches the socket can
|
||||
; stop, start or restart any program, so it is not the shared `selfpost`
|
||||
; group's: Postfix is in that one for the files under /data, and a fault in
|
||||
; it must not reach every other program (audit 2026-10-12, stage 5). Not
|
||||
; reachable by world. No HTTP auth is configured because access is gated by
|
||||
; socket permissions, not the network. (supervisor 4.2.5,
|
||||
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
|
||||
; mode and owner; without username and password no authentication is asked.)
|
||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
|
||||
; still unreachable by world; only root and the two SelfPost services are in the
|
||||
; group. No HTTP auth is configured because access is gated by socket
|
||||
; permissions, not the network.
|
||||
[unix_http_server]
|
||||
file=/run/supervisor.sock
|
||||
chown=root:supervisor
|
||||
chown=root:selfpost
|
||||
chmod=0770
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
@@ -49,10 +33,17 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisor.sock
|
||||
|
||||
; user=panel also gives the panel its supplementary groups, selfpost and
|
||||
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
|
||||
; drop_privileges(), os.setgroups() from the user's memberships in
|
||||
; grp.getgrall().
|
||||
[program:opendkim]
|
||||
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||
priority=100
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:panel]
|
||||
command=/usr/local/bin/panel
|
||||
user=panel
|
||||
@@ -65,44 +56,28 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; startsecs is MILTER_WAIT_TIMEOUT plus ten seconds (build/entrypoint.sh): the
|
||||
; wrapper's exit when the milter sockets never come counts as too quick, and
|
||||
; so does a Postfix that will not start. The price: for that long after each
|
||||
; start the program is STARTING, not RUNNING (supervisor 4.2.5,
|
||||
; supervisor/options.py, processes_from_section(): startsecs is expanded like
|
||||
; any value, %(ENV_x)s from supervisord's environment; assumptions C-V6).
|
||||
; stopwaitsecs covers "postfix stop", which waits five seconds for master
|
||||
; before it kills (build/postfix-wrapper.sh).
|
||||
[program:postfix]
|
||||
command=/usr/local/bin/postfix-wrapper.sh
|
||||
priority=300
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=%(ENV_POSTFIX_STARTSECS)s
|
||||
startretries=3
|
||||
stopwaitsecs=15
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
||||
; reload` itself, and it cannot signal the Postfix master directly: `postfix
|
||||
; start-fg` forks a separate master process, so a signal to the supervised
|
||||
; foreground process never reaches it. Instead the panel asks
|
||||
; supervisord — over the group-accessible control socket — to run this program,
|
||||
; which runs the canonical `postfix reload` as root - or exits 75 when no
|
||||
; master runs (build/postfix-reload.sh). autostart is off (it only runs on
|
||||
; demand). A program that cannot even be spawned twice in a row ends FATAL
|
||||
; (startretries=1); crashexit leaves that to the page that asked for the
|
||||
; reload, which reports it (build/crashexit.py; review of stage 2c, F6). (start-fg: see build/postfix-wrapper.sh. supervisor
|
||||
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
|
||||
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
|
||||
; never FATAL - with its exit status kept, which the panel reads
|
||||
; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first
|
||||
; run, EXITED after it; assumptions C-V10.)
|
||||
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
|
||||
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
|
||||
; directly: `postfix start-fg` forks a separate master process, so a signal to
|
||||
; the supervised foreground process never reaches it (unlike OpenDKIM, which
|
||||
; runs in the foreground as the supervised process itself). Instead the panel
|
||||
; asks supervisord — over the group-accessible control socket — to run this
|
||||
; program, which executes the canonical `postfix reload` as root. autostart is
|
||||
; off (it only runs on demand) and a fast, clean exit is expected, so it never
|
||||
; trips the crashexit listener.
|
||||
[program:postfix-reload]
|
||||
command=/usr/local/bin/postfix-reload.sh
|
||||
command=/usr/sbin/postfix reload
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
@@ -113,9 +88,8 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; TLS-certificate refresh. Once a day it compares the certificate the
|
||||
; reverse-proxy renews in the read-only mount with the copy Postfix reads, and
|
||||
; when they differ copies it again and runs `postfix reload`.
|
||||
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:cert-reload]
|
||||
@@ -129,31 +103,22 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic logrotate for /data/log/mail.log (daily, 14 files kept). Runs as
|
||||
; root so logrotate can read/rotate the log. Exits non-zero at once when the
|
||||
; config is group/other-writable (logrotate would ignore it silently): with
|
||||
; startsecs=1 that exit is too quick; once startretries are spent the program
|
||||
; is FATAL, and Server > Health shows it. With startsecs=0 it was restarted
|
||||
; every second for ever, RUNNING and EXITED in turn (supervisor 4.2.5,
|
||||
; supervisor/process.py, Subprocess.finish(); assumptions C-V5). crashexit
|
||||
; leaves this FATAL alone: logrotate is not on the mail path.
|
||||
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
||||
; in the image). Runs as root so logrotate can read/rotate the log; never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:logrotate]
|
||||
command=/usr/local/bin/logrotate-loop.sh
|
||||
priority=400
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=1
|
||||
startretries=3
|
||||
startsecs=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
|
||||
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
|
||||
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
|
||||
[eventlistener:crashexit]
|
||||
command=/usr/local/bin/crashexit.py
|
||||
events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
|
||||
events=PROCESS_STATE_FATAL
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The panel reads the inbound filter's two variables with the semantics of
|
||||
// build/postfix-config.sh: the action is accept unless it says tempfail, and a
|
||||
// value the script would refuse is not guessed at.
|
||||
func TestAntispamActionMirrorsTheBuildScript(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"": "accept", "accept": "accept", "tempfail": "tempfail",
|
||||
"reject": "", "Accept": "", "TEMPFAIL": "", " accept": "",
|
||||
} {
|
||||
if got := antispamAction(raw); got != want {
|
||||
t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsTheInboundFilter(t *testing.T) {
|
||||
t.Setenv("INBOUND_RELAY_ENABLE", "true")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail")
|
||||
cfg := loadConfig()
|
||||
if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" {
|
||||
t.Errorf("config = %+v", cfg)
|
||||
}
|
||||
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER", "")
|
||||
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "")
|
||||
cfg = loadConfig()
|
||||
if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" {
|
||||
t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func runDMARCIngestMode() error {
|
||||
cfg := loadConfig()
|
||||
if !cfg.dmarcEnabled {
|
||||
return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true")
|
||||
}
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
recipient := ingestRecipient(os.Args[1:])
|
||||
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
|
||||
if !errors.Is(err, dmarc.ErrRejected) {
|
||||
return err
|
||||
}
|
||||
// Not a report this relay keeps. It is dropped: deferring would have
|
||||
// Postfix try it again for days, and a bounce would go to an envelope
|
||||
// sender anyone can write - the report addresses are public. Postfix
|
||||
// 3.10.13: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
|
||||
// ... service ...)" (src/global/pipe_command.c, pipe_command();
|
||||
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
|
||||
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
|
||||
// to the sender (src/qmgr/qmgr_active.c, src/global/bounce.c).
|
||||
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||
}
|
||||
logRejected(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logRejected leaves the reason in the mail log. Postfix puts the start of
|
||||
// what a delivery command wrote - about a hundred bytes - into its own line
|
||||
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
|
||||
// so the reason goes out first and bare: no prefix, no date, no recipient,
|
||||
// all of which that line already has. Postfix 3.10.13, src/pipe/pipe.c,
|
||||
// eval_command_status(): " (%.100s)" of the output, which
|
||||
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
|
||||
// no use here: pipe(8) starts the command without the group that may write to
|
||||
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
|
||||
// user's one group). Nor does postlog bring the group with it: upstream
|
||||
// installs it set-gid, Debian's package does not - measured in the image,
|
||||
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
|
||||
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
|
||||
// postqueue (assumptions A-43). postfix 3.10.13-0+deb13u1 is the same by its
|
||||
// source: debian/postfix.postinst adds statoverrides for /usr/sbin/postdrop
|
||||
// and /usr/sbin/postqueue alone, and nothing in debian/ makes postlog set-gid.
|
||||
func logRejected(reason error) {
|
||||
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
|
||||
}
|
||||
|
||||
// logSafe makes a reason fit for a log line. Parts of it are the sender's own
|
||||
// bytes - a header line, a file name inside an archive, a piece of XML - so it
|
||||
// is cut short and anything that is not printable ASCII becomes "?", whatever
|
||||
// Postfix would have done with it.
|
||||
func logSafe(s string) string {
|
||||
const max = 200
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return '?'
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// ingestRecipient is the report address the message was delivered to. Postfix's
|
||||
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
|
||||
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
|
||||
// Postfix 3.10.13, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
|
||||
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
|
||||
// src/local/command.c sets "RECIPIENT".
|
||||
// Without it the check that a report came to its own domain's address is
|
||||
// skipped.
|
||||
func ingestRecipient(args []string) string {
|
||||
for _, a := range args {
|
||||
if a == "-dmarc-ingest" {
|
||||
continue
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(a))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDMARCIngestInvocation() bool {
|
||||
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
||||
return true
|
||||
}
|
||||
return filepath.Base(os.Args[0]) == "dmarc-ingest"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIngestRecipient(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
// As pipe(8) starts it: the symlink name, then ${recipient}.
|
||||
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
|
||||
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
|
||||
{[]string{"-dmarc-ingest"}, ""},
|
||||
{nil, ""},
|
||||
} {
|
||||
if got := ingestRecipient(tc.args); got != tc.want {
|
||||
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSafe(t *testing.T) {
|
||||
got := logSafe("zip entry \"a\nstatus=bounced\tx\" \x1b[31m é")
|
||||
if want := "zip entry \"a?status=bounced?x\" ?[31m ?"; got != want {
|
||||
t.Errorf("logSafe = %q, want %q", got, want)
|
||||
}
|
||||
long := make([]byte, 1000)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := logSafe(string(long)); len(got) != 200 {
|
||||
t.Errorf("logSafe kept %d bytes of 1000, want 200", len(got))
|
||||
}
|
||||
}
|
||||
@@ -1,138 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The environment the image reads and the environment the documents describe
|
||||
// are compared as they are, not as lists kept by hand beside them (audit
|
||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
||||
// other and with nothing else). Read: what the panel's code asks of the
|
||||
// environment (cmd/panel and internal, tests aside) and what the start-up
|
||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
||||
// operator's table in guide.md § Environment variables, with the paragraph
|
||||
// under it, and the internal list in architecture.md § Configuration.
|
||||
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
}
|
||||
|
||||
// envRead is every variable the code and the scripts read.
|
||||
func envRead(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
||||
if err != nil || len(scripts) == 0 {
|
||||
t.Fatalf("no build scripts: %v", err)
|
||||
}
|
||||
for _, path := range scripts {
|
||||
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
// documentedInternal matches guide.md "Internal variables (not part of the operator interface)".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// section is the text of doc from the first line that starts with start up
|
||||
// to the next that starts with any of ends.
|
||||
func section(t *testing.T, doc, start string, ends ...string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(doc, "\n"+start)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q", start)
|
||||
}
|
||||
rest := doc[i+1+len(start):]
|
||||
end := len(rest)
|
||||
for _, e := range ends {
|
||||
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
|
||||
end = j
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// envDocumented is every variable the documents name, and where.
|
||||
func envDocumented(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
keys := map[string]string{}
|
||||
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
||||
keys[m[1]] = "guide.md § Environment variables"
|
||||
}
|
||||
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
||||
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
||||
if _, ok := keys[m[1]]; !ok {
|
||||
keys[m[1]] = "architecture.md § Configuration"
|
||||
}
|
||||
}
|
||||
return keys
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
}
|
||||
|
||||
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
||||
doc := envDocumented(t)
|
||||
var missing []string
|
||||
for key := range envRead(t) {
|
||||
if _, ok := doc[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(missing)
|
||||
for _, key := range missing {
|
||||
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
|
||||
}
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
||||
read := envRead(t)
|
||||
var stale []string
|
||||
for key, where := range envDocumented(t) {
|
||||
if !read[key] {
|
||||
stale = append(stale, key+" ("+where+")")
|
||||
}
|
||||
}
|
||||
slices.Sort(stale)
|
||||
for _, key := range stale {
|
||||
t.Errorf("%s is documented but nothing reads it", key)
|
||||
}
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
// The parsers find what they are for: a key of each kind is among the read.
|
||||
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
||||
read := envRead(t)
|
||||
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
||||
if !read[key] {
|
||||
t.Errorf("%s not found among the keys read", key)
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range buildScriptKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||
read := append([]string{}, loadConfigKeys...)
|
||||
read = append(read, buildScriptKeys...)
|
||||
slices.Sort(read)
|
||||
read = slices.Compact(read)
|
||||
|
||||
for _, key := range documentedKeys() {
|
||||
if !slices.Contains(read, key) {
|
||||
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,160 +3,43 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web"
|
||||
)
|
||||
|
||||
// mailStack is the panel's domain and application services plus the on-disk
|
||||
// mail-path adapters they write through.
|
||||
type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
Inbound *inbound.Service
|
||||
Lists *inbound.Lists
|
||||
DMARC *dmarc.Service
|
||||
pf *postfix.Postfix
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
|
||||
// The panel's notifications are sent as postmaster@ the server's own name,
|
||||
// and signed as it.
|
||||
domains.SetServerName(cfg.hostname)
|
||||
inb := inbound.NewService(st, pf)
|
||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||
return &mailStack{
|
||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||
Lists: inbound.NewLists(st, cfg.antispamDir),
|
||||
pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Resync rebuilds the selector table and Postfix's maps from SQLite and
|
||||
// reloads Postfix — the same work as the Health page's Reload button.
|
||||
func (m *mailStack) Resync() error {
|
||||
if err := m.Domains.Resync(); err != nil {
|
||||
return fmt.Errorf("dkim resync: %w", err)
|
||||
}
|
||||
if err := m.Apps.Resync(); err != nil {
|
||||
return fmt.Errorf("postfix resync: %w", err)
|
||||
}
|
||||
if m.inboundEnabled {
|
||||
if err := m.Inbound.Resync(); err != nil {
|
||||
return fmt.Errorf("inbound maps resync: %w", err)
|
||||
}
|
||||
// The filter's configuration names all six list files; they exist from
|
||||
// the first start, empty until an entry is added.
|
||||
if err := m.Lists.Sync(); err != nil {
|
||||
return fmt.Errorf("filter lists resync: %w", err)
|
||||
}
|
||||
}
|
||||
if m.dmarcEnabled {
|
||||
if err := m.DMARC.Resync(); err != nil {
|
||||
return fmt.Errorf("dmarc maps resync: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mailStack) skipReloadForTest() {
|
||||
m.pf.SetReloadHook(func() error { return nil })
|
||||
}
|
||||
|
||||
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
||||
// backup restore. testNoReload skips the supervisord reload step so restore
|
||||
// tests can verify file regeneration without a running mail stack.
|
||||
func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
||||
ms := newMailStack(cfg, st)
|
||||
if testNoReload {
|
||||
ms.skipReloadForTest()
|
||||
}
|
||||
return ms.Resync()
|
||||
}
|
||||
|
||||
// newPanel wires the panel's services over the shared database handle and
|
||||
// builds the HTTP application from cfg. It is the composition of the panel as
|
||||
// the environment describes it, with nothing bound to a port yet.
|
||||
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
ms := newMailStack(cfg, st)
|
||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||
// effective config, including a manual override; the panel keeps this
|
||||
// snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
|
||||
// postconf(1): without -d or -n it shows main.cf's value or the default,
|
||||
// and -h drops the "name = " label; values are not $-expanded without -x.
|
||||
retryPolicy := postfix.LoadRetryPolicy()
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
DeployRoot: cfg.deployRoot,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
||||
SignSocket: cfg.signSocket,
|
||||
SignLocalSocket: cfg.signLocalSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SenderSocket: cfg.senderSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||
RetryPolicy: retryPolicy,
|
||||
InboundEnabled: cfg.inboundEnabled,
|
||||
InboundAntispamMilter: cfg.inboundAntispamMilter,
|
||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||
DMARCEnabled: cfg.dmarcEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||
|
||||
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
||||
}, cfg.setupTokenPath)
|
||||
}
|
||||
|
||||
// newHTTPServer bounds what a client may hold the panel's server for (audit
|
||||
// 2026-10-12, L14): the request with its body - a form, or a domain export
|
||||
// of 1 MiB at most - in 30 s; the answer in 60 s, the slowest page being one
|
||||
// that asks DNS; an idle kept-alive connection 2 min. The full backup moves
|
||||
// its own write deadline to two hours: it streams for as long as the archive
|
||||
// takes (handlers.HandleBackup). net/http, Go 1.26: ReadTimeout covers the
|
||||
// header and the body, WriteTimeout runs from the end of the header's read
|
||||
// to the end of the response's write, IdleTimeout is the wait for the next
|
||||
// request on a kept-alive connection.
|
||||
func newHTTPServer(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
srvApp, err := newPanel(cfg, st)
|
||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
||||
// service delegates to them when a domain (and its applications) is deleted.
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||
|
||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +47,11 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
go func() {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The panel's server bounds every phase a client could hold it in (audit
|
||||
// 2026-10-12, L14); before, only the header's read was.
|
||||
func TestHTTPServerHasItsTimeouts(t *testing.T) {
|
||||
s := newHTTPServer(":0", nil)
|
||||
for name, d := range map[string]time.Duration{
|
||||
"ReadHeaderTimeout": s.ReadHeaderTimeout,
|
||||
"ReadTimeout": s.ReadTimeout,
|
||||
"WriteTimeout": s.WriteTimeout,
|
||||
"IdleTimeout": s.IdleTimeout,
|
||||
} {
|
||||
if d <= 0 {
|
||||
t.Errorf("%s is %v", name, d)
|
||||
}
|
||||
}
|
||||
if s.ReadTimeout < s.ReadHeaderTimeout || s.WriteTimeout < 30*time.Second {
|
||||
t.Errorf("read %v (header %v), write %v: too short for a form or a DNS page", s.ReadTimeout, s.ReadHeaderTimeout, s.WriteTimeout)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
@@ -16,65 +15,31 @@ import (
|
||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||
// (architecture.md § Mail path), recording accepted messages into the send
|
||||
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
||||
// in listenMilter; the protocol handler lives in internal/milter.
|
||||
// here; the protocol handler lives in internal/milter.
|
||||
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.journalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("journal-milter listening on %s", cfg.journalSocket)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
|
||||
// serveInboundJournal runs the inbound journal milter, the last milter on
|
||||
// port 25 (internal/milter/inbound.go). It exists only while the inbound relay
|
||||
// is on: build/postfix-config.sh attaches the socket under the same condition.
|
||||
func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.inboundJournalSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// With a filter attached, the key it signs with. Without the key no
|
||||
// verdict can be told from a sender's own, so none is read: the journal
|
||||
// still records the mail, and the log says why it shows no verdicts.
|
||||
key := ""
|
||||
if cfg.inboundAntispamMilter != "" {
|
||||
if key, err = inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("inbound-journal-milter: no key for the filter's signature, verdicts will not be recorded: %v", err)
|
||||
key = ""
|
||||
}
|
||||
}
|
||||
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
|
||||
return milter.ServeInbound(ctx, ln, st, key)
|
||||
}
|
||||
|
||||
// listenMilter opens a milter's Unix socket where Postfix can reach it.
|
||||
func listenMilter(socketPath string) (net.Listener, error) {
|
||||
socketPath := cfg.journalSocket
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use" (unix(7): the socket
|
||||
// file "must be deleted by the caller when it is no longer needed"; bind(2)
|
||||
// EADDRINUSE).
|
||||
// listen below fails with "address already in use".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
// unix(7): "connecting to a stream socket object requires write
|
||||
// permission on that socket"; inode(7): a file made in a set-group-ID
|
||||
// directory takes the directory's group.
|
||||
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return ln, nil
|
||||
|
||||
log.Printf("journal-milter listening on %s", socketPath)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// several roles (architecture.md § Image and processes) as a supervised
|
||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||
// the rate-limit checks.
|
||||
//
|
||||
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -19,33 +17,15 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if isDMARCIngestInvocation() {
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("dmarc-ingest: ")
|
||||
if err := runDMARCIngestMode(); err != nil {
|
||||
log.Printf("ingest failed: %v", err)
|
||||
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
|
||||
// src/global/pipe_command.c, pipe_command(): a sysexits code maps
|
||||
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
|
||||
// unless the output starts with a "D.S.N text" of its own - the
|
||||
// log prefix above makes sure it does not.
|
||||
os.Exit(75)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -67,60 +47,33 @@ func main() {
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
||||
senderSocket string
|
||||
// The DKIM signer's sockets: applications' mail and the panel's own
|
||||
// (cmd/panel/signer.go).
|
||||
signSocket, signLocalSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
dataDir string
|
||||
dbPath string
|
||||
manifestPath string
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
sessionIdleDays int
|
||||
dnsResolvers []string
|
||||
rateLimitMessagesPerIP int
|
||||
rateLimitWindowSeconds int
|
||||
dataDir string
|
||||
dbPath string
|
||||
manifestPath string
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
sessionIdleDays int
|
||||
dnsResolvers []string
|
||||
|
||||
// Read-only input to the panel's status page: the certificate Postfix
|
||||
// serves. The default mirrors build/postfix-config.sh, so the status
|
||||
// page checks exactly what Postfix was configured with.
|
||||
tlsCertFile string
|
||||
// tlsServedCertFile is the certificate Postfix serves (its copy of
|
||||
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
|
||||
// how often build/postfix-cert-reload.sh looks for a renewal.
|
||||
tlsServedCertFile string
|
||||
tlsReloadInterval time.Duration
|
||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||
// serves and the two milter sockets it connects to. The defaults mirror
|
||||
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
|
||||
dkimDir string
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
|
||||
saslDBPath string
|
||||
saslRealm string
|
||||
postfixDir string
|
||||
deployRoot string
|
||||
// Where the filter lists are written as map files for the external spam
|
||||
// filter; the antispam compose fragment mounts it into the sidecar.
|
||||
antispamDir string
|
||||
|
||||
inboundEnabled bool
|
||||
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
|
||||
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
|
||||
// what attaches it; the panel only says so. See antispamAction.
|
||||
inboundAntispamMilter string
|
||||
inboundAntispamAction string
|
||||
// The inbound journal milter's socket; the default mirrors
|
||||
// build/postfix-config.sh. Served only while the inbound relay is on.
|
||||
inboundJournalSocket string
|
||||
// Retention of the inbound journal until Server > Settings says otherwise.
|
||||
inboundSpamLogRetentionDays int
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -128,11 +81,6 @@ func loadConfig() config {
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"),
|
||||
// The DKIM signer's two sockets; the defaults must match
|
||||
// build/postfix-config.sh and build/postfix-wrapper.sh.
|
||||
signSocket: envDefault("DKIM_SIGN_SOCKET", "/run/selfpost/sign.sock"),
|
||||
signLocalSocket: envDefault("DKIM_SIGN_LOCAL_SOCKET", "/run/selfpost/sign-local.sock"),
|
||||
// Postfix's delivery log, under /data so the lines that resolve a
|
||||
// "queued" send-log row outlive the container. The default must match
|
||||
// maillog_file in build/postfix-config.sh.
|
||||
@@ -166,16 +114,12 @@ func loadConfig() config {
|
||||
// means dnscheck's public defaults; a closed network names its own here.
|
||||
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||
|
||||
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
||||
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
|
||||
|
||||
// Per-domain DKIM state (architecture.md § DKIM signing): the keys and
|
||||
// the selector table, the layout entrypoint.sh prepares.
|
||||
dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
|
||||
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||
|
||||
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||
@@ -186,45 +130,12 @@ func loadConfig() config {
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
|
||||
antispamDir: envDefault("ANTISPAM_DIR", filepath.Join(dataDir, "antispam")),
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
||||
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
|
||||
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
|
||||
inboundJournalSocket: envDefault("INBOUND_JOURNAL_MILTER_SOCKET", "/run/selfpost/inbound-journal.sock"),
|
||||
|
||||
inboundSpamLogRetentionDays: envInt("INBOUND_SPAM_LOG_RETENTION_DAYS", store.InboundSpamLogRetentionDaysDefault),
|
||||
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
|
||||
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
|
||||
// the only other values it lets the container start with. Anything else is
|
||||
// returned as "" — the panel says nothing about the action rather than guess.
|
||||
func antispamAction(raw string) string {
|
||||
switch raw {
|
||||
case "", "accept":
|
||||
return "accept"
|
||||
case "tempfail":
|
||||
return "tempfail"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||
// login resolves to the right account. Postfix 3.10.13,
|
||||
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
|
||||
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
|
||||
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
|
||||
// plugins/sasldb.c, sasldb_auxprop_lookup(), and common/plugin_common.c,
|
||||
// _plug_parseuser(): a login
|
||||
// without "@" is then looked up in the realm serverFQDN. A SASL_REALM other
|
||||
// than SELFPOST_HOSTNAME does not follow that - assumptions A-48.
|
||||
// login resolves to the right account.
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
@@ -269,17 +180,10 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
// The prefix length goes with how the address is written: an
|
||||
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
|
||||
// 128-bit address, and "/32" on it is ::/32 - every address
|
||||
// whose first 32 bits are zero (::1 among them) trusted, and the
|
||||
// host meant not at all (audit 2026-10-12, L4). Go 1.26
|
||||
// net.IPNet.Contains compares an IPv4 client with the mapped
|
||||
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
|
||||
if strings.Contains(cidr, ":") {
|
||||
cidr += "/128"
|
||||
} else {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
cidr += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
@@ -292,37 +196,6 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
return nets
|
||||
}
|
||||
|
||||
// openData opens the database the panel runs on. When a backup was unpacked
|
||||
// into /data, the restore guard comes first (architecture.md § Persistence):
|
||||
// the manifest's version must match this binary and the archive must have
|
||||
// been unpacked whole before the database is touched, so neither schema skew
|
||||
// nor half an archive can corrupt the restored state. That first start then
|
||||
// runs resync, and only after it the manifest is consumed: a start that fails
|
||||
// on the way leaves it, and the next start is a restore again (audit
|
||||
// 2026-10-12, M1). Without a manifest it is an ordinary start.
|
||||
func openData(cfg config, resync func(*store.Store) error) (st *store.Store, restored bool, err error) {
|
||||
restored, err = backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
st, err = store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if restored {
|
||||
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||
if err := resync(st); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
if err := backup.ConsumeRestore(cfg.manifestPath); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
return st, restored, nil
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all
|
||||
// roles; a role error cancels the others and is returned so the process exits
|
||||
@@ -330,74 +203,32 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
|
||||
// Image and processes).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
// postfix-config.sh has written main.cf before supervisord started the
|
||||
// panel (build/entrypoint.sh), and nothing rewrites it after.
|
||||
cfg.tlsServedCertFile = health.ServedCertPath()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||
|
||||
// Restore version guard (architecture.md § Persistence): if a backup was
|
||||
// extracted into /data, its manifest version must match this binary before we
|
||||
// touch the database, so schema/format skew between versions cannot corrupt
|
||||
// the restored state. A match consumes the manifest; its absence is the
|
||||
// normal (non-restore) case.
|
||||
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One database handle shared by every role. The store serialises writes
|
||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||
// can all use it without stepping on each other under WAL.
|
||||
st, restored, err := openData(cfg, func(st *store.Store) error {
|
||||
return resyncAfterRestore(cfg, st, false)
|
||||
})
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if !restored && cfg.dmarcEnabled {
|
||||
ms := newMailStack(cfg, st)
|
||||
ms.pf.SetReloadHook(func() error { return nil })
|
||||
if err := ms.DMARC.Resync(); err != nil {
|
||||
log.Printf("dmarc maps bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter lists as files, on every start: the spam filter's
|
||||
// configuration names all six, and a data directory from before the lists
|
||||
// existed has none. Not fatal - the lists are the filter's business, and
|
||||
// the panel has mail to relay either way.
|
||||
if cfg.inboundEnabled && !restored {
|
||||
if err := inbound.NewLists(st, cfg.antispamDir).Sync(); err != nil {
|
||||
log.Printf("filter lists bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
// The key the filter signs with has to be in the shared directory before
|
||||
// the filter reads it, whether or not a filter is attached yet.
|
||||
if cfg.inboundEnabled {
|
||||
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
|
||||
log.Printf("filter key bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
||||
}
|
||||
// With the inbound relay on, the tailer also writes the inbound journal's
|
||||
// rows for refused mail and prunes that journal.
|
||||
var inboundTail *logtail.Inbound
|
||||
if cfg.inboundEnabled {
|
||||
if err := st.EnsureInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays); err != nil {
|
||||
return fmt.Errorf("bootstrap inbound journal retention: %w", err)
|
||||
}
|
||||
inboundTail = &logtail.Inbound{Store: st, Retention: func() int {
|
||||
days, err := st.GetInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays)
|
||||
if err != nil {
|
||||
log.Printf("inbound journal retention: %v", err)
|
||||
return cfg.inboundSpamLogRetentionDays
|
||||
}
|
||||
return days
|
||||
}}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// Room for every role to report a failure without blocking: eight at most.
|
||||
errc := make(chan error, 8)
|
||||
errc := make(chan error, 3)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
@@ -405,30 +236,7 @@ func run() error {
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
|
||||
{"dkim-signer", func(ctx context.Context) error { return serveSigner(ctx, cfg, st) }},
|
||||
{"log-tailer", func(ctx context.Context) error {
|
||||
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||
if err != nil {
|
||||
log.Printf("send-log retention: %v", err)
|
||||
return cfg.retentionDays
|
||||
}
|
||||
return days
|
||||
}, inboundTail, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
|
||||
}},
|
||||
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||
return nil
|
||||
}},
|
||||
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
|
||||
}
|
||||
|
||||
if cfg.inboundEnabled {
|
||||
roles = append(roles, struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{"inbound-journal-milter", func(ctx context.Context) error { return serveInboundJournal(ctx, cfg, st) }})
|
||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runNotifier writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications), until ctx is cancelled. It reads what the
|
||||
// pages read - a domain's published records against the key this server signs
|
||||
// with, the certificate file, Postfix's queue - with a DNS checker of its own,
|
||||
// so that its hourly look is never a page's cached one.
|
||||
func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ms := newMailStack(cfg, st)
|
||||
dns := dnscheck.New(cfg.dnsResolvers)
|
||||
|
||||
n := notify.New(st, notify.Checks{
|
||||
Domain: func(d store.Domain) (dnscheck.Domain, error) {
|
||||
rec, err := ms.Domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
return dnscheck.Domain{}, err
|
||||
}
|
||||
// The server's own addresses are cached for a minute: one lookup
|
||||
// for a pass over all the domains.
|
||||
srv := dns.Server(cfg.hostname, false)
|
||||
return dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: rec.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: d.DMARCRua,
|
||||
}, true), nil
|
||||
},
|
||||
Certificate: func() health.Certificate {
|
||||
return health.CheckServedCertificate(cfg.tlsServedCertFile, cfg.tlsCertFile, cfg.tlsReloadInterval, time.Now())
|
||||
},
|
||||
Queue: postfix.QueueMessages,
|
||||
}, notify.NewMailer(cfg.hostname), cfg.hostname)
|
||||
return n.Run(ctx)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bare address is one host however it is written; an IPv4-mapped one in
|
||||
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
|
||||
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entry string
|
||||
trusts []string
|
||||
refuses []string
|
||||
}{
|
||||
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
|
||||
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
|
||||
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
|
||||
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
|
||||
} {
|
||||
nets := parseTrustedProxies(c.entry)
|
||||
if len(nets) != 1 {
|
||||
t.Fatalf("%q parsed as %v", c.entry, nets)
|
||||
}
|
||||
for _, ip := range c.trusts {
|
||||
if !nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range c.refuses {
|
||||
if nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runAutoRateLimitRecalc recomputes auto-mode level-2 limits on the same
|
||||
// interval as send-log retention pruning (6 hours).
|
||||
func runAutoRateLimitRecalc(ctx context.Context, cfg config, st *store.Store) {
|
||||
recalc := func() {
|
||||
retention := cfg.retentionDays
|
||||
if days, err := st.GetSendLogRetentionDays(cfg.retentionDays); err == nil {
|
||||
retention = days
|
||||
}
|
||||
l1Max := cfg.rateLimitMessagesPerIP
|
||||
if l1Max <= 0 {
|
||||
l1Max = 100
|
||||
}
|
||||
l1Window := cfg.rateLimitWindowSeconds
|
||||
if l1Window <= 0 {
|
||||
l1Window = 3600
|
||||
}
|
||||
n, err := st.RecalcAllAutoRateLimits(retention, l1Max, l1Window)
|
||||
if err != nil {
|
||||
log.Printf("auto rate-limit recalc: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("auto rate-limit recalc: updated %d limit(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
recalc()
|
||||
t := time.NewTicker(logtail.RetentionInterval())
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
recalc()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// A restore that goes wrong half way must leave the operator where they
|
||||
// started: the archive is refused before the database is opened, and a start
|
||||
// that fails after the guard is a restore again the next time (audit
|
||||
// 2026-10-12, M1 and stage 3).
|
||||
|
||||
// A start that fails after the guard - here its Resync - leaves the manifest,
|
||||
// so the next start is a restore again and runs the Resync the failed one did
|
||||
// not finish. The failed start had the database open, so its files are no
|
||||
// longer the archive's; the next start does not compare them again.
|
||||
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
broken := errors.New("postfix reload failed")
|
||||
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
|
||||
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Fatalf("the failed start consumed the manifest: %v", err)
|
||||
}
|
||||
|
||||
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
|
||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||
t.Fatalf("write stale map: %v", err)
|
||||
}
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the start after a failed one: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if !restored {
|
||||
t.Fatal("the start after a failed one did not take the data directory for a restore")
|
||||
}
|
||||
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
|
||||
t.Errorf("sender map = %q: the second start did not run the Resync", got)
|
||||
}
|
||||
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
|
||||
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, restored, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("an ordinary start ran the restore Resync")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the ordinary start after the restore: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
if restored {
|
||||
t.Error("the start after a finished restore took it for a restore again")
|
||||
}
|
||||
}
|
||||
|
||||
// An archive unpacked in part - a download cut short, a tar stopped by a
|
||||
// damaged block - is refused before the database is opened, with a message
|
||||
// that says so, and nothing in the directory is changed: the database the
|
||||
// archive left is as it left it, and the manifest is there for the next try.
|
||||
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
|
||||
archive := archiveOf(t, seedPanelProject(t))
|
||||
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cut string
|
||||
at int64
|
||||
}{
|
||||
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
|
||||
{"before the deploy files", backup.ComposeFileName, 0},
|
||||
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extractCut(t, archive, target, c.cut, c.at)
|
||||
cfg := panelConfig(t, target)
|
||||
before, err := os.ReadFile(cfg.dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("the cut archive left no database: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
|
||||
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
|
||||
}
|
||||
|
||||
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
|
||||
t.Error("the refused start changed the database")
|
||||
}
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
|
||||
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(cfg.manifestPath); err != nil {
|
||||
t.Errorf("the refused start removed the manifest: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A file the archive holds and the data directory holds otherwise - damaged
|
||||
// on the way, or unpacked from another archive - is refused by its name.
|
||||
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
extract(t, archiveOf(t, seedPanelProject(t)), target)
|
||||
cfg := panelConfig(t, target)
|
||||
|
||||
key := filepath.Join(cfg.dataDir, "dkim", "keys", restoreDomain, "selfpost.private")
|
||||
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open key: %v", err)
|
||||
}
|
||||
f.WriteString("x")
|
||||
f.Close()
|
||||
|
||||
_, _, err = openData(cfg, func(*store.Store) error {
|
||||
t.Error("a refused restore ran its Resync")
|
||||
return nil
|
||||
})
|
||||
want := "data/dkim/keys/" + restoreDomain + "/selfpost.private"
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// archiveOf takes a full backup of a seeded project for this binary's version.
|
||||
func archiveOf(t *testing.T, deployRoot string) []byte {
|
||||
t.Helper()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
var archive bytes.Buffer
|
||||
if err := backup.Create(&archive, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||
Version: buildinfo.Version,
|
||||
DeployRoot: deployRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
return archive.Bytes()
|
||||
}
|
||||
|
||||
// entrySize is the size of the archive's entry name.
|
||||
func entrySize(t *testing.T, archive []byte, name string) int64 {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
t.Fatalf("the archive has no %s", name)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the archive: %v", err)
|
||||
}
|
||||
if hdr.Name == name {
|
||||
return hdr.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
||||
// extracts the archive into a project directory and starts the image, and the
|
||||
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
||||
// below stubs that story out — the archive is downloaded from a running panel
|
||||
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||
// is started on the result through the same startup sequence run() uses:
|
||||
// openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
|
||||
// Start.
|
||||
|
||||
const (
|
||||
restorePassword = "correct-horse-battery"
|
||||
restoreDomain = "bs.example.ru"
|
||||
restoreSubject = "Order confirmation"
|
||||
)
|
||||
|
||||
// restored is the outcome of a full backup-and-restore round trip.
|
||||
type restored struct {
|
||||
panel http.Handler // panel booted on the restored data directory
|
||||
deployRoot string // the restored project directory
|
||||
dataDir string // the restored /data
|
||||
session *http.Cookie // a session opened before the backup was taken
|
||||
}
|
||||
|
||||
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||
// has been in use, sign in, download a backup from it, extract that archive
|
||||
// into an empty directory and boot a second panel there. A non-empty password
|
||||
// takes the encrypted download and decrypts it on the way in, which is what an
|
||||
// operator does with a .spbk file.
|
||||
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||
t.Helper()
|
||||
|
||||
live := seedPanelProject(t)
|
||||
panel := bootPanel(t, live)
|
||||
session := signIn(t, panel)
|
||||
archive := downloadBackup(t, panel, session, password)
|
||||
|
||||
target := t.TempDir()
|
||||
extract(t, archive, target)
|
||||
|
||||
return restored{
|
||||
panel: bootPanel(t, target),
|
||||
deployRoot: target,
|
||||
dataDir: filepath.Join(target, "data"),
|
||||
session: session,
|
||||
}
|
||||
}
|
||||
|
||||
// The panel has to come up on the restored directory and show the state that
|
||||
// was in the archive, without the operator touching anything else: the domain
|
||||
// and its journal are in the database the archive carried, and the credentials
|
||||
// that worked before the restore still work after it.
|
||||
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "")
|
||||
|
||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
||||
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The archive carries the daemon files, and the first boot after restore
|
||||
// re-derives the maps from SQLite so they stay aligned with the database.
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
||||
if err != nil {
|
||||
t.Errorf("the restored data directory has no %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
|
||||
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
|
||||
t.Errorf("the restored project directory has no %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one-time setup link is closed by the presence of a panel user, and the
|
||||
// restored database has one. A restore that reopened it would publish a link
|
||||
// that creates a second global administrator on a server holding live mail
|
||||
// credentials (security.md).
|
||||
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
|
||||
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
|
||||
}
|
||||
body := getPage(t, r.panel, "/login", nil)
|
||||
if strings.Contains(body, "No administrator has been created yet") {
|
||||
t.Errorf("the restored panel offers first-run setup:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions live in the database, so they travel in the archive: a cookie that
|
||||
// was valid when the backup was taken is valid again on the restored panel.
|
||||
// That is the documented consequence of restoring an older backup (guide §
|
||||
// Backup and restore) — stated here so it cannot change by accident.
|
||||
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "")
|
||||
|
||||
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An encrypted download is the same archive inside an envelope, so it restores
|
||||
// the same way once the password is supplied. The archive is never written to
|
||||
// disk in the clear by the panel, so this is the only place the two paths can
|
||||
// be shown to agree.
|
||||
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||
|
||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
||||
if !strings.Contains(body, restoreSubject) {
|
||||
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
||||
// drifted from the database, that step puts it back before mail flows.
|
||||
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||
deployRoot := seedPanelProject(t)
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||
t.Fatalf("write stale map: %v", err)
|
||||
}
|
||||
|
||||
manifest, err := json.Marshal(backup.Manifest{
|
||||
Format: backup.FormatFull,
|
||||
Version: buildinfo.Version,
|
||||
CreatedAt: "2026-08-14T00:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manifest: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
// A record of no files: this test is about the maps, and the files it
|
||||
// seeded were never in an archive.
|
||||
writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
|
||||
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("open the restored data: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if !restored {
|
||||
t.Fatal("the start did not take the data directory for a restore")
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(mapPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read sender map: %v", err)
|
||||
}
|
||||
want := "@" + restoreDomain + " shop\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("sender map = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The version guard is what stops a restore from being silently corrupted by
|
||||
// schema skew, and it runs before anything opens the database. The manifest
|
||||
// stays put on a mismatch: the operator's next move is to start the image the
|
||||
// backup names, and it has to be there when they do.
|
||||
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||
deployRoot := seedPanelProject(t)
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
var archive bytes.Buffer
|
||||
if err := backup.Create(&archive, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||
Version: "9.9.9",
|
||||
DeployRoot: deployRoot,
|
||||
}); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
target := t.TempDir()
|
||||
extract(t, archive.Bytes(), target)
|
||||
|
||||
cfg := panelConfig(t, target)
|
||||
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||
if err == nil {
|
||||
t.Fatal("the panel booted on a data directory left by another version")
|
||||
}
|
||||
for _, want := range []string{"9.9.9", buildinfo.Version} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
|
||||
}
|
||||
}
|
||||
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
|
||||
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
||||
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
||||
func seedPanelProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
deployRoot := t.TempDir()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dom, err := st.AddDomain(restoreDomain, "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatalf("add application: %v", err)
|
||||
}
|
||||
if err := st.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
|
||||
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert send-log row: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
|
||||
for path, content := range map[string]string{
|
||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||
} {
|
||||
full := filepath.Join(dataDir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", full, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", full, err)
|
||||
}
|
||||
}
|
||||
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||
return deployRoot
|
||||
}
|
||||
|
||||
func writeDeployFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// panelConfig resolves the panel's own configuration for a project directory, so
|
||||
// the test finds the files where the running binary would look for them rather
|
||||
// than where it put them. Cookies are marked insecure for the same reason the
|
||||
// e2e stand does it: the test client speaks plain HTTP.
|
||||
func panelConfig(t *testing.T, deployRoot string) config {
|
||||
t.Helper()
|
||||
dataDir := filepath.Join(deployRoot, "data")
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||
// directory; without this the panel would read the host's /data.
|
||||
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||
// and returns the panel's HTTP handler.
|
||||
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||
t.Helper()
|
||||
cfg := panelConfig(t, deployRoot)
|
||||
|
||||
st, restored, err := openData(cfg, testResync(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
if restored {
|
||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||
}
|
||||
}
|
||||
|
||||
panel, err := newPanel(cfg, st)
|
||||
if err != nil {
|
||||
t.Fatalf("build the panel: %v", err)
|
||||
}
|
||||
if err := panel.Start(); err != nil {
|
||||
t.Fatalf("start the panel: %v", err)
|
||||
}
|
||||
return panel.Handler()
|
||||
}
|
||||
|
||||
// signIn signs in as the seeded administrator and returns the session cookie.
|
||||
func signIn(t *testing.T, h http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
|
||||
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("sign in issued no session cookie")
|
||||
}
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
// downloadBackup takes a backup through the panel's own /backup route, the way
|
||||
// the operator does. An empty password downloads the plain archive; otherwise
|
||||
// the response is a .spbk envelope, which is decrypted here.
|
||||
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
|
||||
t.Helper()
|
||||
form := url.Values{}
|
||||
if password != "" {
|
||||
form.Set("encrypt", "1")
|
||||
form.Set("password", password)
|
||||
form.Set("password_confirm", password)
|
||||
}
|
||||
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
|
||||
}
|
||||
|
||||
body := rec.Body.Bytes()
|
||||
if password == "" {
|
||||
if secretfile.HasMagic(body) {
|
||||
t.Fatal("an unencrypted download came back as an envelope")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
if !secretfile.HasMagic(body) {
|
||||
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
|
||||
}
|
||||
r, err := secretfile.NewReader(bytes.NewReader(body), password)
|
||||
if err != nil {
|
||||
t.Fatalf("open the encrypted backup: %v", err)
|
||||
}
|
||||
plain, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt the backup: %v", err)
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||
// directory before the image is started.
|
||||
func extract(t *testing.T, archive []byte, dir string) {
|
||||
t.Helper()
|
||||
extractCut(t, archive, dir, "", 0)
|
||||
}
|
||||
|
||||
// extractCut unpacks the archive as extract does, except that the stream ends
|
||||
// cutAt bytes into the entry named cut: that entry is written as far as the
|
||||
// stream went and nothing after it is, which is what tar leaves of a download
|
||||
// cut short or a damaged block. An empty cut unpacks the whole archive.
|
||||
func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("the download is not a gzip stream: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
if cut != "" {
|
||||
t.Fatalf("the archive has no entry %s to cut", cut)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read the archive: %v", err)
|
||||
}
|
||||
name := filepath.Clean(filepath.FromSlash(hdr.Name))
|
||||
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
||||
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
var body io.Reader = tr
|
||||
if hdr.Name == cut {
|
||||
body = io.LimitReader(tr, cutAt)
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if hdr.Name == cut {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testResync is the Resync a restore start runs, without reloading daemons
|
||||
// the test does not have.
|
||||
func testResync(cfg config) func(*store.Store) error {
|
||||
return func(st *store.Store) error { return resyncAfterRestore(cfg, st, true) }
|
||||
}
|
||||
|
||||
// writeContents writes the record of an archive's files into dataDir, as the
|
||||
// archive's last entry would have.
|
||||
func writeContents(t *testing.T, dataDir string, c backup.Contents) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal contents: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dataDir, backup.ContentsName), body, 0o600); err != nil {
|
||||
t.Fatalf("write contents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getPage performs a GET and returns the body, failing on any non-200.
|
||||
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
|
||||
t.Helper()
|
||||
rec := request(t, h, http.MethodGet, target, nil, session)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// request drives the panel's real handler chain, including the origin check,
|
||||
// with the headers a browser on the panel's own page would send.
|
||||
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
|
||||
req.Host = "mail.example.ru"
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
}
|
||||
if session != nil {
|
||||
req.AddCookie(session)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// aliveEvery is how often the sender check marks itself alive. The gap a
|
||||
// restart finds since the last mark is the check's downtime, so the mark's
|
||||
// age is the error of that figure.
|
||||
const aliveEvery = 2 * time.Second
|
||||
|
||||
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
|
||||
// socket of its own. Postfix attaches that socket strictly
|
||||
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.10.13,
|
||||
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
|
||||
// try again later"), so while this role is not serving, mail is deferred:
|
||||
// what that costs is counted here and
|
||||
// by the log tailer, and shown on Server > Health
|
||||
// (docs/plans/from-header-binding.md § Statistics).
|
||||
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ln, err := listenMilter(cfg.senderSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("sender-check listening on %s", cfg.senderSocket)
|
||||
|
||||
// Statistics are the database's business and supervisord's, and the check
|
||||
// is neither: they are kept beside it, and nothing of them is waited for
|
||||
// before it serves.
|
||||
now := time.Now()
|
||||
mark := cfg.senderSocket + ".alive"
|
||||
// The mark's age is read before the mark is kept again: asking supervisord
|
||||
// may take half a minute, and the mark must not go stale meanwhile, or a
|
||||
// restart in that half minute would count it as downtime.
|
||||
var markAt time.Time
|
||||
if fi, err := os.Stat(mark); err == nil {
|
||||
markAt = fi.ModTime()
|
||||
}
|
||||
go keepAlive(ctx, mark, st)
|
||||
go func() {
|
||||
if err := st.StartSenderCheckStats(now); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
if down, estimated := senderCheckDowntime(markAt, now, postfixStarted); down > 0 {
|
||||
if estimated {
|
||||
log.Printf("sender-check: was not answering for at most %s (supervisord did not say since when Postfix ran)", down.Round(time.Second))
|
||||
} else {
|
||||
log.Printf("sender-check: was not answering for %s while Postfix was running", down.Round(time.Second))
|
||||
}
|
||||
for _, part := range byDay(now.Add(-down), now) {
|
||||
if err := st.AddSenderCheckDown(part.day, part.down, estimated); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// The table the mail path itself goes by: whom Postfix lets send as what.
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
return milter.ServeSender(ctx, ln, bind, st)
|
||||
}
|
||||
|
||||
// postfixState is what supervisord said about Postfix: since when it runs, if
|
||||
// it does; known is false when supervisord did not answer.
|
||||
type postfixState struct {
|
||||
started time.Time
|
||||
running, known bool
|
||||
}
|
||||
|
||||
// senderCheckDowntime is for how long the check was not serving before now
|
||||
// while Postfix was: the time since the mark was last set (markAt; zero
|
||||
// when there is none), or since
|
||||
// Postfix started if that was later. No mark means the container is new, and
|
||||
// a Postfix that is not running means mail was stopped for another reason -
|
||||
// a rollout, a reboot - and neither is the check's downtime. When supervisord
|
||||
// could not say, the time since the mark is taken and estimated is true: an
|
||||
// upper bound, since Postfix may not have been running all of it (audit
|
||||
// 2026-10-12, stage 2b - until then such a start counted nothing).
|
||||
func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down time.Duration, estimated bool) {
|
||||
if markAt.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
from := markAt
|
||||
p := ask()
|
||||
switch {
|
||||
case !p.known:
|
||||
estimated = true
|
||||
case !p.running:
|
||||
return 0, false
|
||||
case p.started.After(from):
|
||||
from = p.started
|
||||
}
|
||||
if d := now.Sub(from); d > 0 {
|
||||
return d, estimated
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// dayPart is the part of a stretch of downtime that fell on one UTC day.
|
||||
type dayPart struct {
|
||||
day time.Time
|
||||
down time.Duration
|
||||
}
|
||||
|
||||
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
|
||||
// so that a night's outage is not all booked on the morning it ended.
|
||||
func byDay(from, to time.Time) []dayPart {
|
||||
from, to = from.UTC(), to.UTC()
|
||||
var parts []dayPart
|
||||
for from.Before(to) {
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
if next.After(to) {
|
||||
next = to
|
||||
}
|
||||
parts = append(parts, dayPart{day: from, down: next.Sub(from)})
|
||||
from = next
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// postfixAskFor is how long postfixStarted keeps asking a supervisord that
|
||||
// does not answer - at the container's start it may not be listening yet.
|
||||
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
|
||||
|
||||
// postfixStarted asks supervisord since when Postfix has been running, again
|
||||
// and again for postfixAskFor while it does not answer. supervisor 4.2.5,
|
||||
// supervisor/rpcinterface.py, _interpretProcessInfo(): the uptime runs from
|
||||
// the spawn of program "postfix", which is build/postfix-wrapper.sh; it waits
|
||||
// for the milter sockets (up to MILTER_WAIT_TIMEOUT) before it execs
|
||||
// "postfix start-fg", so the time given is up to that much early
|
||||
// (assumptions A-46). For startsecs - that wait plus ten seconds
|
||||
// (build/supervisord.conf) - after its start the program is STARTING, not
|
||||
// RUNNING, and is taken here as not running: a panel restart within that
|
||||
// time after Postfix's own counts no downtime.
|
||||
func postfixStarted() postfixState {
|
||||
deadline := time.Now().Add(postfixAskFor)
|
||||
for {
|
||||
procs, err := health.Processes()
|
||||
if err == nil {
|
||||
for _, p := range procs {
|
||||
if p.Name != "postfix" || p.State != "RUNNING" {
|
||||
continue
|
||||
}
|
||||
if up, ok := health.Uptime(p.Detail); ok {
|
||||
return postfixState{started: time.Now().Add(-up), running: true, known: true}
|
||||
}
|
||||
}
|
||||
return postfixState{known: true}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Said aloud: this start's downtime is then an estimate.
|
||||
log.Printf("sender-check: statistics: when Postfix started is not known: %v", err)
|
||||
return postfixState{}
|
||||
}
|
||||
time.Sleep(postfixAskEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive sets the mark until ctx ends, and once a day drops the days of
|
||||
// the statistics that are older than a year.
|
||||
func keepAlive(ctx context.Context, path string, st *store.Store) {
|
||||
touch := func() {
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(path, now, now); err != nil {
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
log.Printf("sender-check: alive mark: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
touch()
|
||||
tick := time.NewTicker(aliveEvery)
|
||||
defer tick.Stop()
|
||||
prune := time.NewTicker(24 * time.Hour)
|
||||
defer prune.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
touch()
|
||||
case <-prune.C:
|
||||
if err := st.PruneSenderCheckDays(time.Now()); err != nil {
|
||||
log.Printf("sender-check: statistics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The check's downtime is the time since its last mark, under a Postfix that
|
||||
// was running: a new container has no mark, and a Postfix that is not up, or
|
||||
// came up later than the mark, was not kept waiting by the check for all of
|
||||
// that time.
|
||||
func TestSenderCheckDowntime(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC)
|
||||
var mark time.Time // the alive mark's time; zero is no mark
|
||||
set := func(at time.Time) { mark = at }
|
||||
postfix := func(started time.Time, running bool) func() postfixState {
|
||||
return func() postfixState { return postfixState{started: started, running: running, known: true} }
|
||||
}
|
||||
silent := func() postfixState { return postfixState{} }
|
||||
down := func(ask func() postfixState) (time.Duration, bool) { return senderCheckDowntime(mark, now, ask) }
|
||||
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("no mark: %s", d)
|
||||
}
|
||||
set(now.Add(-40 * time.Second))
|
||||
if d, est := down(postfix(now.Add(-time.Hour), true)); d != 40*time.Second || est {
|
||||
t.Errorf("Postfix up throughout: %s %v, want 40s, not estimated", d, est)
|
||||
}
|
||||
if d, _ := down(postfix(now.Add(-10*time.Second), true)); d != 10*time.Second {
|
||||
t.Errorf("Postfix started within the gap: %s, want 10s", d)
|
||||
}
|
||||
if d, _ := down(postfix(time.Time{}, false)); d != 0 {
|
||||
t.Errorf("Postfix not running: %s", d)
|
||||
}
|
||||
// supervisord did not answer: the time since the mark, an estimate.
|
||||
if d, est := down(silent); d != 40*time.Second || !est {
|
||||
t.Errorf("supervisord silent: %s %v, want 40s, estimated", d, est)
|
||||
}
|
||||
set(now.Add(time.Minute)) // a clock set back
|
||||
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
|
||||
t.Errorf("a mark in the future: %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Downtime is booked on the days it fell on.
|
||||
func TestByDay(t *testing.T) {
|
||||
at := func(d, h, m int) time.Time { return time.Date(2026, 10, d, h, m, 0, 0, time.UTC) }
|
||||
got := byDay(at(12, 23, 50), at(14, 0, 10))
|
||||
want := []dayPart{{at(12, 23, 50), 10 * time.Minute}, {at(13, 0, 0), 24 * time.Hour}, {at(14, 0, 0), 10 * time.Minute}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parts = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].day.Equal(want[i].day) || got[i].down != want[i].down {
|
||||
t.Errorf("part %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if one := byDay(at(12, 10, 0), at(12, 10, 0).Add(40*time.Second)); len(one) != 1 || one[0].down != 40*time.Second {
|
||||
t.Errorf("within a day: %+v", one)
|
||||
}
|
||||
if none := byDay(at(12, 10, 0), at(12, 10, 0)); len(none) != 0 {
|
||||
t.Errorf("no time: %+v", none)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// serveSigner runs the DKIM signer (internal/milter/sign.go) on its two
|
||||
// sockets: signSocket for what applications send on 465/587, signed with the
|
||||
// key of the domain the login belongs to - the sender_login_maps Postfix and
|
||||
// the sender check read (Bindings.DomainOf); signLocalSocket for what the
|
||||
// panel itself submits with sendmail(1), signed with the key of the server's
|
||||
// name. Postfix attaches both strictly (default_action=tempfail,
|
||||
// build/postfix-config.sh), so while this role is not serving, mail waits.
|
||||
//
|
||||
// The keys and selectors come from the key tree and the selector table under
|
||||
// dkimDir (internal/domain, Keys). Before it serves, the table is made to
|
||||
// hold the server's own name - and written at all on the first start of
|
||||
// 2.8.0, whose table replaced OpenDKIM's: Postfix waits for these sockets
|
||||
// (build/postfix-wrapper.sh), so no message meets the table before it.
|
||||
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
|
||||
if rebuilt, err := newMailStack(cfg, st).Domains.EnsureServerSigned(); err != nil {
|
||||
// Mail waits: what the signer cannot find a key for it defers.
|
||||
log.Printf("dkim-signer: the selector table: %v", err)
|
||||
} else if rebuilt {
|
||||
log.Printf("dkim-signer: the selector table was rewritten from the registry")
|
||||
}
|
||||
keys := milter.NewSignKeys(filepath.Join(cfg.dkimDir, "selectors"), filepath.Join(cfg.dkimDir, "keys"))
|
||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||
server := strings.ToLower(cfg.hostname)
|
||||
|
||||
apps, err := listenMilter(cfg.signSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
local, err := listenMilter(cfg.signLocalSocket)
|
||||
if err != nil {
|
||||
apps.Close()
|
||||
return err
|
||||
}
|
||||
log.Printf("dkim-signer listening on %s (applications) and %s (the panel's own mail, as %s)", cfg.signSocket, cfg.signLocalSocket, server)
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- milter.ServeSign(ctx, apps, keys, bind.DomainOf) }()
|
||||
go func() {
|
||||
errc <- milter.ServeSign(ctx, local, keys, func(string) string { return server })
|
||||
}()
|
||||
// The first to stop decides: an error stops the panel (run()), and on a
|
||||
// cancelled ctx both return nil.
|
||||
if err := <-errc; err != nil {
|
||||
return err
|
||||
}
|
||||
return <-errc
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
//
|
||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
//
|
||||
// Use -o to write to a file instead. The resulting archive is self-contained:
|
||||
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
||||
// secret (architecture.md § Persistence).
|
||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
||||
// (architecture.md § Persistence).
|
||||
//
|
||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||
// argument, which would show up in the process list) the archive is written as
|
||||
@@ -16,26 +16,19 @@
|
||||
// the same password:
|
||||
//
|
||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
//
|
||||
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// passwordEnv names the environment variable holding the encryption password.
|
||||
@@ -75,20 +68,13 @@ func run(outPath, password string) error {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
|
||||
// The floor the panel holds the same files to: an encrypted backup is
|
||||
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
|
||||
// empty password is no encryption, by design.
|
||||
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
|
||||
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
|
||||
}
|
||||
|
||||
w, err := openOutput(outPath)
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.abort()
|
||||
defer closeOut()
|
||||
|
||||
var sink io.Writer = w
|
||||
sink := w
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
@@ -99,13 +85,9 @@ func run(outPath, password string) error {
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
OnWarn: func(msg string) {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
|
||||
},
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -114,9 +96,6 @@ func run(outPath, password string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
kind := "plain"
|
||||
if password != "" {
|
||||
@@ -148,28 +127,17 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The first chunk is opened before anything is written: a wrong password
|
||||
// is told before the output is touched, and the envelope's type is
|
||||
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
|
||||
// 2026-10-12, L1).
|
||||
plain := bufio.NewReaderSize(env, 64<<10)
|
||||
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
if env.Type() != secretfile.TypeFullBackup {
|
||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||
}
|
||||
|
||||
w, err := openOutput(outPath)
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.abort()
|
||||
defer closeOut()
|
||||
|
||||
if _, err := io.Copy(w, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.commit(); err != nil {
|
||||
if _, err := io.Copy(w, env); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
@@ -178,61 +146,17 @@ func runDecrypt(inPath, outPath, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// output is where the CLI writes: stdout, or a file that takes its name only
|
||||
// once it is whole. A backup or a decryption that fails half way - a wrong
|
||||
// password, an envelope cut short - leaves whatever had the name before, and
|
||||
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
|
||||
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
|
||||
// backups are secret enough to keep off other users' eyes.
|
||||
type output struct {
|
||||
io.Writer
|
||||
tmp *os.File // nil for stdout
|
||||
path string
|
||||
}
|
||||
|
||||
func openOutput(outPath string) (*output, error) {
|
||||
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||
if outPath == "" {
|
||||
return &output{Writer: os.Stdout}, nil
|
||||
return os.Stdout, func() {}, nil
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return &output{Writer: f, tmp: f, path: outPath}, nil
|
||||
}
|
||||
|
||||
// commit puts the whole file under its name.
|
||||
func (o *output) commit() error {
|
||||
if o.tmp == nil {
|
||||
return nil
|
||||
}
|
||||
f := o.tmp
|
||||
o.tmp = nil
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(f.Name(), o.path); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return err
|
||||
}
|
||||
_ = atomicfile.SyncDir(filepath.Dir(o.path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// abort removes a file that was not committed; after commit it does nothing.
|
||||
func (o *output) abort() {
|
||||
if o.tmp == nil {
|
||||
return
|
||||
}
|
||||
o.tmp.Close()
|
||||
os.Remove(o.tmp.Name())
|
||||
o.tmp = nil
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
}
|
||||
|
||||
// readPassword takes the password from the given file (first line) or, when no
|
||||
|
||||
@@ -9,18 +9,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// seedProject builds the minimum operator project tree a backup can be taken from.
|
||||
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
||||
func seedDataDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
deployRoot = t.TempDir()
|
||||
dataDir = filepath.Join(deployRoot, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
@@ -31,31 +26,16 @@ func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||
return deployRoot, dataDir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return dataDir
|
||||
}
|
||||
|
||||
// An encrypted backup is only worth having if the container it came from can
|
||||
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||
// the CLI are tested as the one round trip an operator actually performs.
|
||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
seedProject(t)
|
||||
seedDataDir(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
plain := filepath.Join(dir, "backup.tar.gz")
|
||||
@@ -101,12 +81,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
}
|
||||
names[hdr.Name] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
backup.DataArchivePrefix + backup.ManifestName,
|
||||
backup.DataArchivePrefix + "selfpost.db",
|
||||
backup.ComposeFileName,
|
||||
backup.EnvFileName,
|
||||
} {
|
||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
||||
if !names[want] {
|
||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||
}
|
||||
@@ -116,7 +91,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
// Without a password the CLI keeps producing the plain archive that existing
|
||||
// backup scripts consume.
|
||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||
seedProject(t)
|
||||
seedDataDir(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
@@ -162,76 +137,3 @@ func TestReadPassword(t *testing.T) {
|
||||
t.Error("-decrypt without a password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// An encrypted backup is held to the panel's password floor (audit
|
||||
// 2026-10-12, M5); a refused one writes nothing.
|
||||
func TestEncryptionPasswordFloor(t *testing.T) {
|
||||
seedProject(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.spbk")
|
||||
err := run(out, "eleven-char")
|
||||
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
|
||||
t.Fatalf("an 11-character password = %v, want refused", err)
|
||||
}
|
||||
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the refused backup wrote %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
|
||||
// 2026-10-12, M3).
|
||||
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
|
||||
_, dataDir := seedProject(t)
|
||||
missing := filepath.Join(dataDir, "typo.db")
|
||||
t.Setenv("SELFPOST_DB_PATH", missing)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err == nil {
|
||||
t.Fatal("a backup of a database that is not there was made")
|
||||
}
|
||||
for _, p := range []string{missing, out} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -decrypt -o writes its file whole or not at all: a wrong password or an
|
||||
// envelope cut short leaves the file that had the name before, and nothing
|
||||
// else in its directory (audit 2026-10-12, L1 and stage 3).
|
||||
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
|
||||
seedProject(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
const password = "a long enough password"
|
||||
if err := run(encrypted, password); err != nil {
|
||||
t.Fatalf("create encrypted backup: %v", err)
|
||||
}
|
||||
sealed, err := os.ReadFile(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := filepath.Join(dir, "cut.spbk")
|
||||
writeFile(t, cut, string(sealed[:len(sealed)-100]))
|
||||
|
||||
outDir := t.TempDir()
|
||||
out := filepath.Join(outDir, "backup.tar.gz")
|
||||
const before = "the archive that was here"
|
||||
for _, c := range []struct{ name, in, password, want string }{
|
||||
{"wrong password", encrypted, "the wrong password", "wrong password"},
|
||||
{"cut short", cut, password, "corrupted"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
writeFile(t, out, before)
|
||||
err := runDecrypt(c.in, out, c.password)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
|
||||
}
|
||||
if got, _ := os.ReadFile(out); string(got) != before {
|
||||
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
|
||||
}
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("the failed decryption left files beside its output: %v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,34 +11,6 @@ SELFPOST_HOSTNAME=mail.example.com
|
||||
# primary 465/smtps listener.
|
||||
SUBMISSION_ENABLE=false
|
||||
|
||||
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
|
||||
# mail on port 25 only for domains configured in the panel and forwards them
|
||||
# to the upstream you set. Off by default — the outbound path is unchanged.
|
||||
INBOUND_RELAY_ENABLE=false
|
||||
|
||||
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
|
||||
# 25 only for report addresses configured in the panel (global default and/or
|
||||
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
|
||||
DMARC_REPORTS_ENABLE=false
|
||||
|
||||
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
|
||||
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# DMARC_MESSAGE_SIZE_LIMIT=5242880
|
||||
|
||||
# Optional milter on the inbound listener only (not 465/587). Empty = off.
|
||||
# Example with deploy/antispam/docker-compose.antispam.yml:
|
||||
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||
# INBOUND_ANTISPAM_MILTER_ACTION=accept
|
||||
|
||||
# Days the inbound journal keeps a row (7-365) until the panel says otherwise;
|
||||
# it never holds more than 10 000 rows.
|
||||
# INBOUND_SPAM_LOG_RETENTION_DAYS=90
|
||||
|
||||
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
|
||||
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
||||
|
||||
|
||||
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
|
||||
#
|
||||
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
|
||||
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
|
||||
# only when you want filtering on port 25:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||||
#
|
||||
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
|
||||
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
|
||||
#
|
||||
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
|
||||
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) -
|
||||
# and it is the build the comments in antispam/rspamd/ were read against;
|
||||
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
|
||||
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||
# defer instead.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
environment:
|
||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
|
||||
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
depends_on:
|
||||
- antispam
|
||||
|
||||
antispam:
|
||||
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
|
||||
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
|
||||
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
|
||||
# (its Dockerfile, USER 11333:11333).
|
||||
image: rspamd/rspamd:3.11.1-3
|
||||
restart: unless-stopped
|
||||
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||
volumes:
|
||||
# The verdict headers SelfPost's inbound journal reads.
|
||||
- ./antispam/rspamd/milter_headers.conf:/etc/rspamd/local.d/milter_headers.conf:ro
|
||||
# The allow and deny lists kept in the panel: the rules, and the files
|
||||
# SelfPost writes them to.
|
||||
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro
|
||||
- ./data/antispam:/etc/rspamd/selfpost:ro
|
||||
@@ -1,75 +0,0 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/milter_headers.conf.
|
||||
#
|
||||
# rspamd writes its verdict into two headers of every message it lets through:
|
||||
# the action it chose and the score with the symbols behind it. SelfPost's
|
||||
# inbound journal reads them (Inbound > Log shows them next to the message),
|
||||
# and the upstream mailbox server receives them with the mail.
|
||||
#
|
||||
# rspamd removes headers of these names that a sender wrote himself before it
|
||||
# adds its own - for every client, because of the two lines below. Without
|
||||
# them it would do neither for a client it counts as local or one that
|
||||
# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua,
|
||||
# skip_wanted() - skip_local and skip_authenticated are on by default, and
|
||||
# "local" is loopback, a unix socket, or an address in local_addrs, the
|
||||
# private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The
|
||||
# sender's own verdict would then pass untouched while the signature below,
|
||||
# a custom routine that skip_wanted() never consults, signed it (assumptions
|
||||
# C-R7). A private client address is ordinary here: a LAN sender, or every
|
||||
# sender when Docker's proxy relays port 25 from the bridge gateway.
|
||||
# Tried on 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 (rspamc --ucl, a
|
||||
# message carrying its own X-Spamd-Result and X-Rspamd-Action): without the
|
||||
# two lines, from 10.0.0.5 or as an authenticated user, the milter reply
|
||||
# removed neither header, added neither, and added the signature; with
|
||||
# them, both removed and rspamd's own added, as from a public address.
|
||||
#
|
||||
# Without this file the journal still lists accepted mail, with no verdict
|
||||
# next to it.
|
||||
#
|
||||
# The third header is the proof that the other two are rspamd's. The filter is
|
||||
# attached so that mail still flows when it is down, and then nothing removes
|
||||
# what a sender wrote: he could send his own verdict. So rspamd signs each
|
||||
# message - an HMAC-SHA256 of the message's queue id under a key SelfPost
|
||||
# generates and leaves in the directory it shares with this container - and
|
||||
# SelfPost reads a verdict only next to a signature it can recompute. The
|
||||
# signature travels on with the mail and is of no use to whoever sees it: it
|
||||
# fits that one queue id.
|
||||
#
|
||||
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
|
||||
# as function(task, common_meta) and returns error, headers to add, headers to
|
||||
# remove, metadata; a remove value of 0 removes every header of that name
|
||||
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
|
||||
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
|
||||
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
|
||||
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
|
||||
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
|
||||
# back in src/libserver/protocol.c), which Postfix sends at
|
||||
# end-of-message (Postfix 3.10.13, src/global/mail_params.h:3555,
|
||||
# DEF_MILT_EOD_MACROS "i").
|
||||
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||
skip_local = false;
|
||||
skip_authenticated = false;
|
||||
|
||||
custom {
|
||||
selfpost-signature = <<EOD
|
||||
return function(task, common_meta)
|
||||
local remove = { ['X-SelfPost-Filter'] = 0 }
|
||||
-- The key is read for every message and kept nowhere: SelfPost may make a
|
||||
-- new one (the file was lost, a backup was restored), and a key remembered
|
||||
-- here would go on signing what SelfPost no longer accepts until someone
|
||||
-- restarted this container. The file is one short line.
|
||||
local key
|
||||
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
|
||||
if f then
|
||||
key = f:read('*l')
|
||||
f:close()
|
||||
end
|
||||
local qid = task:get_queue_id()
|
||||
if not key or key == '' or not qid or qid == '' then
|
||||
return nil, {}, remove, {}
|
||||
end
|
||||
local hash = require "rspamd_cryptobox_hash"
|
||||
local sig = hash.create_specific_keyed(key, 'sha256', qid):hex()
|
||||
return nil, { ['X-SelfPost-Filter'] = sig }, remove, {}
|
||||
end
|
||||
EOD;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/multimap.conf.
|
||||
#
|
||||
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
|
||||
# writes them as six files under data/antispam/, which the fragment mounts at
|
||||
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
|
||||
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
|
||||
# ev_stat - inotify where the kernel has it, else a stat poll every
|
||||
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
|
||||
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
|
||||
# internal/inbound writes with.)
|
||||
#
|
||||
# Deny wins over allow, whichever entries match. A denied sender is refused
|
||||
# whatever else is found (the rule's action is a pre-result, below). An
|
||||
# allowed one is not accepted outright - rspamd would then let whichever of
|
||||
# the two rules ran first decide - but given a weight no spam score
|
||||
# outweighs, which a refusal by the deny list still overrides. (rspamd
|
||||
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
|
||||
# "normal" symbol, one with an action with the "passthrough" augmentation;
|
||||
# no "prefilter" setting is read. Measured with this file in a throwaway
|
||||
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
|
||||
# matching one message - address, domain, IP - came out reject, and a
|
||||
# changed map file was in use about ten seconds later.)
|
||||
#
|
||||
# A sender rule looks at the envelope sender only, extract_from = "smtp":
|
||||
# by default multimap.lua takes the header From: when the envelope sender is
|
||||
# empty (the "from" callback, has_from('smtp') then has_from('mime')), so a
|
||||
# bounce would be judged by an address the panel never counts it under
|
||||
# (assumptions C-R4). Tried on 2026-10-14 in rspamd/rspamd:3.11.1-3: a
|
||||
# bounce whose From: domain is on the deny list was refused by default and
|
||||
# is not with this setting; a sender on the list is refused either way.
|
||||
#
|
||||
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
|
||||
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
|
||||
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
|
||||
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
|
||||
#
|
||||
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
|
||||
# with the rule's action and message, and that action stands whatever the score
|
||||
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
|
||||
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
|
||||
# puts it into messages.smtp_message, src/libserver/milter.c,
|
||||
# rspamd_milter_send_task_results(), replies with it.
|
||||
#
|
||||
# SelfPost counts how often an entry decided a message by what these rules
|
||||
# leave behind: the deny message below and the names of the allow symbols.
|
||||
# Change either and Inbound > Filter lists shows "never" for every entry.
|
||||
|
||||
SELFPOST_DENY_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/deny_ip.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Client address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/deny_from.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender address on the deny list";
|
||||
}
|
||||
SELFPOST_DENY_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/deny_domain.map";
|
||||
action = "reject";
|
||||
message = "Refused by this server's deny list";
|
||||
description = "Sender domain on the deny list";
|
||||
}
|
||||
|
||||
SELFPOST_ALLOW_IP {
|
||||
type = "ip";
|
||||
map = "/etc/rspamd/selfpost/allow_ip.map";
|
||||
score = -100.0;
|
||||
description = "Client address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_FROM {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
map = "/etc/rspamd/selfpost/allow_from.map";
|
||||
score = -100.0;
|
||||
description = "Sender address on the allow list";
|
||||
}
|
||||
SELFPOST_ALLOW_DOMAIN {
|
||||
type = "from";
|
||||
extract_from = "smtp";
|
||||
filter = "email:domain";
|
||||
map = "/etc/rspamd/selfpost/allow_domain.map";
|
||||
score = -100.0;
|
||||
description = "Sender domain on the allow list";
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
||||
# on the host running Apache — NOT inside the SelfPost container. Requires
|
||||
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
||||
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
||||
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
||||
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
|
||||
# (`a2enmod ssl proxy proxy_http`).
|
||||
#
|
||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
||||
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
||||
# redirect vhost, and renews in the background via its own timer. The resulting
|
||||
# PEM files land at
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
|
||||
# as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
|
||||
# to them and mount /etc/letsencrypt read-only at the same path (see
|
||||
# ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
|
||||
# present the very same certificate on 465/587. Mounting live/mail.example.com
|
||||
# alone gives the container links into a directory it does not have. One
|
||||
# certificate, two consumers — no separate cert just for the panel.
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
||||
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
|
||||
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
|
||||
# The resulting PEM files land at
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
|
||||
# — bind-mount that directory read-only into the SelfPost container (see
|
||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||
# can present the very same certificate on 465/587. One certificate, two
|
||||
# consumers (spec 10 p.4) — no separate cert just for the panel.
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName mail.example.com
|
||||
@@ -27,9 +26,9 @@
|
||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||
|
||||
# The panel is plain HTTP behind the proxy; it never terminates TLS itself.
|
||||
# Cookies are still marked Secure by the panel because the browser only ever
|
||||
# sees this HTTPS vhost.
|
||||
# The panel is plain HTTP behind the proxy; it never terminates TLS
|
||||
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
||||
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:80>
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
|
||||
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
|
||||
# with no separate certbot container. Caddy writes certificates as plain PEM
|
||||
# under its own data directory, which this fragment bind-mounts to the host so
|
||||
# SelfPost can read the same files.
|
||||
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
||||
# automatic ACME with no separate certbot container. Caddy writes certificates
|
||||
# as plain PEM under its own data directory, which this fragment bind-mounts
|
||||
# to the host so SelfPost can read the same files.
|
||||
#
|
||||
# NOTE: Caddy's on-disk cert path includes
|
||||
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
|
||||
# the ACME CA's name as a path segment, e.g.
|
||||
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||
# This has been stable across recent Caddy releases but is an internal
|
||||
# storage detail, not a documented public API — VERIFY the exact path against
|
||||
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||
# before wiring it into the volume mount below.
|
||||
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
|
||||
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
|
||||
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
|
||||
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
|
||||
# second issuer only when an email is configured, and the Caddyfile below sets
|
||||
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||
|
||||
@@ -31,7 +23,6 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
environment:
|
||||
# Caddy names certificate files after the hostname, not
|
||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||
@@ -40,7 +31,6 @@ services:
|
||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
# SelfPost — default deployment, reverse-proxy = Apache.
|
||||
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
||||
#
|
||||
# This file only runs SelfPost itself. Apache is assumed to already be installed
|
||||
# on the HOST (the target audience for this project typically runs Apache there
|
||||
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
||||
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
||||
# docs/guide.md "Reverse proxy (mandatory)" for the certbot steps that produce the PEM files
|
||||
# this compose file mounts.
|
||||
# This file only runs SelfPost itself. Apache is assumed to already be
|
||||
# installed on the HOST (the target audience for this project typically runs
|
||||
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
|
||||
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
|
||||
# vhost fragment, and docs/guide.md "Reverse proxy" for the
|
||||
# certbot steps that produce the PEM files this compose file mounts.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||
@@ -15,42 +15,32 @@
|
||||
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
||||
# version against the running binary's version, so ":latest" would make that
|
||||
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
||||
# The 2.x line is not on ghcr.io yet: build it locally under this exact name
|
||||
# (README, Quick start) and Compose will use it without pulling.
|
||||
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
|
||||
# restore compares the manifest version against the running binary's version,
|
||||
# so ":latest" would make that check meaningless. Bump the tag deliberately
|
||||
# when you want to upgrade.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.7.0
|
||||
image: ghcr.io/mixeme/selfpost:1.0.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
# Path Postfix reads inside the container — matches the certs bind mount
|
||||
# below. Point these at your reverse-proxy's PEM output.
|
||||
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
|
||||
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
|
||||
# the primary 465/smtps listener.
|
||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||
# alongside the primary 465/smtps listener (spec 5).
|
||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
||||
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
|
||||
# configured later from the panel itself.
|
||||
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||
# limits (level 2) are configured later from the panel itself.
|
||||
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
# How long the send log keeps rows before the background sweep deletes
|
||||
# them — the main driver of /data growth over time.
|
||||
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||
INBOUND_SPAM_LOG_RETENTION_DAYS: "${INBOUND_SPAM_LOG_RETENTION_DAYS:-90}"
|
||||
# Sliding idle timeout for the panel login session, in days.
|
||||
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||
# report what the internet sees rather than what this host's own stub
|
||||
@@ -61,50 +51,38 @@ services:
|
||||
# client spoof its rate-limit key.
|
||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||
volumes:
|
||||
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
|
||||
# sender map, setup token. Back this up (panel button or the
|
||||
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
|
||||
# here too (./data/log, rotated daily, 14 files kept) so the delivery
|
||||
# lines that resolve a queued send-log row survive a container recreate;
|
||||
# it is diagnostics and is left out of backups.
|
||||
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||
# sasldb2, sender map, setup token. Back this up (panel button or the
|
||||
# selfpost-backup CLI) before you touch it directly.
|
||||
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
||||
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||
# a container recreate; it is diagnostics and is left out of backups.
|
||||
- ./data:/data
|
||||
# Read-only project root for full backups: docker-compose.yml, .env, and
|
||||
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||
- .:/selfpost-deploy:ro
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them.
|
||||
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
|
||||
# links to where your reverse proxy's certbot keeps them.
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
# With certbot on the host (docs/guide.md, "Reverse proxy (mandatory)", Apache):
|
||||
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
|
||||
# files are themselves relative links into archive/ - so the whole
|
||||
# directory is mounted, at the same path, for the links to lead
|
||||
# somewhere inside the container. Uncomment it then.
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
ports:
|
||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
|
||||
# NOT published here: Apache reaches it over the host network at
|
||||
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
|
||||
# reachable from the internet without TLS.
|
||||
# published directly — mail traffic bypasses Apache entirely, it only
|
||||
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
||||
# (8080) is intentionally NOT published here: Apache reaches it over
|
||||
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
||||
# panel is never directly reachable from the internet without TLS.
|
||||
- "465:465"
|
||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||
# when the variable is true — see docs/guide.md "Ports".
|
||||
# when the variable is true — see docs/guide.md "Published ports".
|
||||
- "587:587"
|
||||
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||
# when the variable is true.
|
||||
- "25:25"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# Hardening. SelfPost's entrypoint still needs to run as
|
||||
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||
# root very briefly to fix /data ownership and normalise permissions
|
||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||
# self-healing. What IS applied: no privilege escalation past what the
|
||||
# image already grants, and every Linux capability dropped except the
|
||||
# small set the root startup phase and Postfix genuinely need:
|
||||
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||
# dirs/files while still root (owner-check bypass);
|
||||
@@ -113,28 +91,15 @@ services:
|
||||
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
||||
# `panel` user; Postfix switches to its own users;
|
||||
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
||||
# - KILL — root signals processes of other uids: supervisord
|
||||
# stops and restarts the panel (supervisor 4.2.5,
|
||||
# supervisor/process.py:467, options.kill()), and
|
||||
# Postfix's master ends its children, which run as
|
||||
# `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
|
||||
# master_sigdeath(); master_spawn.c:365,
|
||||
# master_delete_children()). Without it kill() fails
|
||||
# with EPERM although the caller is root (kill(2),
|
||||
# capabilities(7); measured on the test server
|
||||
# 2026-10-16: root with SETUID/SETGID and no KILL
|
||||
# cannot kill a process of uid 65534). Until 2.8.0
|
||||
# it also carried OpenDKIM's USR1 reload.
|
||||
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
|
||||
# different uid) on domain add/remove to reload its
|
||||
# KeyTable/SigningTable; without it os.kill() fails
|
||||
# with EPERM even though the caller is root, because
|
||||
# the kernel's signal permission check for
|
||||
# cross-uid kill() still consults CAP_KILL.
|
||||
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||
# self-healing; without them chmod fails with EPERM and the container
|
||||
# crash-loops on start.
|
||||
# Each capability above as capabilities(7) defines it (man7.org,
|
||||
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
|
||||
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
|
||||
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
|
||||
# no-new-privileges only changes execve() (Linux,
|
||||
# Documentation/userspace-api/no_new_privs.rst): root may still call
|
||||
# setuid()/setgid() with these capabilities.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
|
||||
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
|
||||
# Apache+certbot scenario — nginx and certbot are close cousins here, both
|
||||
# writing/reading plain PEM under /etc/letsencrypt.
|
||||
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
||||
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
||||
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
||||
#
|
||||
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
|
||||
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
|
||||
# takes over 80/443 and proxies to the panel over the compose network
|
||||
# instead). Merge it with the base file — run this from the deploy/ directory
|
||||
# (Compose resolves every relative path in both files against the directory of
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
#
|
||||
# First-run certificate issuance, before nginx has a certificate to serve -
|
||||
# run once, with port 80 free (certbot answers the challenge itself;
|
||||
# --webroot would need nginx already serving /.well-known). --entrypoint is
|
||||
# needed: the service's own entrypoint is the renewal loop below, an
|
||||
# `sh -c` that ignores any arguments given to `run`
|
||||
# (docs.docker.com/reference/compose-file/services/#entrypoint; assumptions
|
||||
# D-guide:301):
|
||||
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||
# serve — run once):
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
# run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
|
||||
# run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
|
||||
services:
|
||||
@@ -30,19 +23,14 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
# No host publish for 8080 here: nginx reaches it over the compose
|
||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# certs/ holds two links, fullchain.pem and privkey.pem, to
|
||||
# /etc/letsencrypt/live/mail.example.com/ - a path inside the
|
||||
# container: certbot's directory below is mounted there whole, because
|
||||
# its live/ files are relative links into archive/ (mounting live/ alone
|
||||
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
||||
# Same host directory certbot below writes into — plain bind mount,
|
||||
# no named volume, so the PEM files are as directly inspectable as in
|
||||
# the Apache scenario (spec 10.3).
|
||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
@@ -64,11 +52,4 @@ services:
|
||||
- ./nginx/certbot-www:/var/www/certbot
|
||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||
# until a certificate is within its renewal window.
|
||||
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
|
||||
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
|
||||
# certificate near expiry.) The first certificate was issued --standalone
|
||||
# (see the top of this file), and certbot renews with the plugin it was
|
||||
# issued with "unless you specify other plugins or options" (same page,
|
||||
# "Renewing certificates") - by then nginx holds port 80 and serves the
|
||||
# webroot, so renewal names it; a renewal that succeeds with it saves it.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done'
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
|
||||
# compose network; mail (465/587) is published directly by the selfpost
|
||||
# container and never touches nginx.
|
||||
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
||||
# over the compose network; mail (465/587) is published directly by the
|
||||
# selfpost container and never touches nginx.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@@ -17,8 +17,6 @@ server {
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
|
||||
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
|
||||
http2 on;
|
||||
server_name mail.example.com;
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
|
||||
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
|
||||
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
|
||||
# separate PEM files for Postfix, so an extraction step is required; see
|
||||
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
||||
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
||||
# needs separate PEM files for Postfix, so an extraction step is required; see
|
||||
# extract-cert.sh in this directory and the cron/systemd-timer note below.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
|
||||
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
|
||||
# evaluated relative to the base file"):
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
@@ -17,20 +15,15 @@ services:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "25:25"
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
- .:/selfpost-deploy:ro
|
||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||
labels:
|
||||
# Traefik only proxies the panel; it never sees the mail ports.
|
||||
# These live on the selfpost service (not traefik's) because the
|
||||
# docker provider reads routing labels off the container being routed
|
||||
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
|
||||
# docker/, "Routing Configuration": "Traefik uses container labels";
|
||||
# with exposedByDefault=false only containers labelled
|
||||
# traefik.enable=true.)
|
||||
# to, not off traefik itself.
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||
|
||||
@@ -1,75 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
||||
# Traefik keeps its certificates there, so a PEM extraction step is required.
|
||||
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
||||
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
||||
# by SelfPost/Postfix.
|
||||
#!/bin/sh
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
||||
# is not itself watched by SelfPost/Postfix.
|
||||
#
|
||||
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
#
|
||||
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
|
||||
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
|
||||
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
|
||||
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
|
||||
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
|
||||
# docker-compose.traefik.yml names.
|
||||
#
|
||||
# Nothing is replaced unless both new files are PEM: a domain acme.json does
|
||||
# not hold, an empty field or a broken file stops the script with an error and
|
||||
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
|
||||
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
|
||||
# certificate; Postfix's TLS broke at its next restart). The new files are
|
||||
# written beside the old ones, the owner's alone until they are whole, then
|
||||
# renamed into place.
|
||||
set -euo pipefail
|
||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
set -eu
|
||||
|
||||
ACME_JSON="${1:?path to acme.json}"
|
||||
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
umask 077
|
||||
|
||||
die() {
|
||||
echo "extract-cert: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
|
||||
|
||||
# field prints the base64 of one field of the domain's first certificate, or
|
||||
# nothing.
|
||||
field() {
|
||||
jq -r --arg domain "$DOMAIN" --arg field "$1" '
|
||||
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
|
||||
' "$ACME_JSON"
|
||||
}
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||
|
||||
cert_b64=$(field certificate)
|
||||
key_b64=$(field key)
|
||||
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
|
||||
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
|
||||
|
||||
tmp_cert="" tmp_key=""
|
||||
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
|
||||
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
|
||||
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
|
||||
|
||||
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
|
||||
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
|
||||
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
|
||||
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
|
||||
|
||||
chmod 0640 "$tmp_cert" "$tmp_key"
|
||||
# The group the files had stays theirs: the guide gives the key to group 10998
|
||||
# so the panel can read it for a full backup, and a run from root's cron would
|
||||
# otherwise hand it to root's group (review of audit stage 5).
|
||||
for f in fullchain privkey; do
|
||||
if [ -e "$OUT_DIR/$f.pem" ]; then
|
||||
tmp=$tmp_cert
|
||||
[ "$f" = privkey ] && tmp=$tmp_key
|
||||
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
|
||||
fi
|
||||
done
|
||||
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
|
||||
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
|
||||
trap - EXIT
|
||||
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||
|
||||
|
After Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,22 +0,0 @@
|
||||
/* Cover sheet for the three directions. No library on purpose: it must not
|
||||
look like any one of them. */
|
||||
body { margin: 0; background: var(--sp-paper); color: var(--sp-ink); font: 16px/1.55 var(--sp-sans); }
|
||||
main { max-width: 68rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; }
|
||||
h1 { font-size: 2rem; font-weight: 600; margin: .2rem 0 1rem; text-wrap: balance; }
|
||||
h2 { font-size: 1.15rem; font-weight: 600; margin: 2.6rem 0 .8rem; }
|
||||
h3 { font-size: 1.4rem; font-weight: 600; margin: .1rem 0 .6rem; }
|
||||
.kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-brick); margin: 0; }
|
||||
.lead { max-width: 46rem; color: var(--sp-ink-2); }
|
||||
ul { padding-left: 1.2rem; max-width: 52rem; }
|
||||
li { margin-bottom: .5rem; }
|
||||
a { color: var(--sp-brick); }
|
||||
code { font-family: var(--sp-mono); font-size: .85em; background: var(--sp-well); padding: .1em .35em; border-radius: 3px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.2rem; }
|
||||
.cards section { background: var(--sp-sheet); border: 1px solid var(--sp-rule); border-top: 3px solid var(--sp-brick); padding: 1.3rem 1.4rem; display: flex; flex-direction: column; }
|
||||
.cards p { margin: 0 0 1rem; }
|
||||
.links { margin-top: auto !important; margin-bottom: 0 !important; display: flex; gap: 1.1rem; font-weight: 600; }
|
||||
.scroll { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; background: var(--sp-sheet); border: 1px solid var(--sp-rule); font-size: .93rem; }
|
||||
th, td { text-align: left; vertical-align: top; padding: .6rem .9rem; border-bottom: 1px solid var(--sp-rule); }
|
||||
thead th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
|
||||
tbody th { font-weight: 600; white-space: nowrap; }
|
||||
@@ -1,67 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Panel mockups</title>
|
||||
<link rel="stylesheet" href="shared/brand.css">
|
||||
<link rel="stylesheet" href="index.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="kicker">SelfPost · panel mockups</p>
|
||||
<h1>Every screen of the panel</h1>
|
||||
<p class="lead">The accepted design (<a href="../../plans/panel-redesign.md">the plan</a>): Bulma as the base, the
|
||||
important things in view, details one or two clicks away, with a postal signature — the postmark verdict, numbered
|
||||
mono section heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and Tabler icons.
|
||||
The screens and a components page live in <a href="panel/overview.html">panel/</a>; a page of the running panel that
|
||||
disagrees with its mockup is wrong. Serve this folder from the repository root: the pages load the panel's own fonts
|
||||
and mark by relative path. The three directions of the redesign's first stage and the earlier 1.x mockups were
|
||||
removed on 2026-10-16; they are in the git history.</p>
|
||||
|
||||
<h2>Screen map</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/login-no-admin.html">sign in before an administrator exists</a> · <a href="panel/setup.html">Create administrator</a></td></tr>
|
||||
<tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr>
|
||||
<tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> ·
|
||||
<a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
|
||||
<a href="panel/out-app-created.html">password shown once</a> · <a href="panel/out-domain-delete.html">delete domain</a><br>
|
||||
<a href="panel/out-log.html">Log</a> · <a href="panel/out-message.html">message</a> ·
|
||||
<a href="panel/out-queue.html">Queue</a> · <a href="panel/dmarc.html">DMARC reports</a> ·
|
||||
<a href="panel/dmarc-domain.html">by domain</a> · <a href="panel/dmarc-report.html">one report</a></td></tr>
|
||||
<tr><th>Inbound</th><td><a href="panel/in-domains.html">Domains</a> · <a href="panel/in-domain.html">domain</a> ·
|
||||
<a href="panel/in-domain-delete.html">delete domain</a> · <a href="panel/in-log.html">Log</a> ·
|
||||
<a href="panel/in-queue.html">Queue</a> (empty state)<br>
|
||||
<a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> ·
|
||||
<a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr>
|
||||
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> ·
|
||||
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
|
||||
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
|
||||
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
|
||||
<tr><th>Contract</th><td><a href="panel/components.html">Components</a> — every building block in every state ·
|
||||
<a href="../../plans/panel-redesign.md">the plan</a> · <code>panel/check.py</code> lints the mockups and writes <code>panel/outlines.json</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>What changed against the 1.x panel</h2>
|
||||
<ul>
|
||||
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
|
||||
<em>Server › Health</em>, one click from each card.</li>
|
||||
<li><strong>Every page is one of three layouts</strong>: a list (form on top, table below), a detail page (sticky side menu +
|
||||
numbered boxes), or a form page (boxes in equal columns). No page picks its own width.</li>
|
||||
<li><strong>Own password is Account</strong>, instance defaults are Settings. Export sits with the domain, import with Backup,
|
||||
and each links to the other.</li>
|
||||
<li><strong>Queues are tables</strong>, not a <code>postqueue -p</code> dump — this needs <code>postqueue -j</code> parsing.
|
||||
The inbound queue is the same queue filtered by recipient domain.</li>
|
||||
<li><strong>Confirm pages say what will be lost</strong> as a list, beside a hint for the usual alternative (export before delete).</li>
|
||||
<li>Own components on top of Bulma: postmark, perforated edge, numbered box head, health card, DNS record block, facts grid,
|
||||
timeline, log pane. The mockup's <code>theme.css</code> is about 200 lines.</li>
|
||||
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
|
||||
so nothing has to be run to view them.</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,115 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Account · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Signed in as admin · global</p>
|
||||
<h1 class="title is-3">Account</h1>
|
||||
<p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="cur">Current password</label><div class="control"><input class="input" id="cur" name="current_password" type="password" autocomplete="current-password" required></div></div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label" for="new">New password</label><div class="control"><input class="input" id="new" name="new_password" type="password" autocomplete="new-password" required></div></div>
|
||||
<div class="field"><label class="label" for="new2">Repeat</label><div class="control"><input class="input" id="new2" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
|
||||
</div>
|
||||
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
|
||||
<button type="submit" class="button">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
/* Rendered-page audit for the panel: what check.py cannot see because it needs
|
||||
a browser. Open any page of the mockups (or of the running panel), paste this
|
||||
file into the console, then:
|
||||
|
||||
await panelAudit(["overview.html", "out-domain.html", ...]) // same-origin URLs
|
||||
await panelAudit() // the page you are on
|
||||
|
||||
It loads each page in a 1440px and a 1280px frame and reports
|
||||
- text under the 13px floor (the postmark's imprint excepted),
|
||||
- text under 4.5:1 against the ground it actually sits on,
|
||||
- anything wider than its box, and tables that have to scroll,
|
||||
- health-card values that got clipped.
|
||||
An empty `problems` list is the pass. Attach the output to the step's evidence
|
||||
(docs/plans/panel-redesign.md, Enforcement). */
|
||||
async function panelAudit(urls = [location.href], widths = [1440, 1280]) {
|
||||
const lum = (c) => {
|
||||
const [r, g, b] = c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; };
|
||||
const ground = (el, win) => {
|
||||
for (let e = el; e; e = e.parentElement) { const b = rgba(win.getComputedStyle(e).backgroundColor); if (b.a > 0.5) return b.rgb; }
|
||||
return [255, 255, 255];
|
||||
};
|
||||
const problems = new Set();
|
||||
let texts = 0;
|
||||
for (const url of urls) for (const width of widths) {
|
||||
const f = document.createElement("iframe");
|
||||
f.width = width; f.height = 900; f.src = url;
|
||||
document.body.appendChild(f);
|
||||
await new Promise((r) => (f.onload = r));
|
||||
await f.contentDocument.fonts.ready;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const d = f.contentDocument, win = f.contentWindow, at = `${width} ${url.split("/").pop()}`;
|
||||
const walker = d.createTreeWalker(d.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n; (n = walker.nextNode()); ) {
|
||||
const text = n.textContent.trim(), el = n.parentElement;
|
||||
if (!text || el.closest(".sp-log")) continue; // the log pane has its own dark ground and palette
|
||||
texts++;
|
||||
const cs = win.getComputedStyle(el), what = `${el.tagName.toLowerCase()}.${[...el.classList].join(".")} "${text.slice(0, 24)}"`;
|
||||
if (parseFloat(cs.fontSize) < 12.9 && !el.closest(".sp-postmark")) problems.add(`${at}: ${parseFloat(cs.fontSize)}px ${what}`);
|
||||
const fg = rgba(cs.color).rgb, bg = ground(el, win), a = lum(fg), b = lum(bg);
|
||||
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.6 && +cs.fontWeight >= 600);
|
||||
if (ratio < (large ? 3 : 4.5)) problems.add(`${at}: contrast ${ratio.toFixed(2)} ${what}`);
|
||||
}
|
||||
if (d.documentElement.scrollWidth > win.innerWidth) problems.add(`${at}: the page scrolls sideways`);
|
||||
for (const e of d.querySelectorAll(".box, .sp-head, .sp-box-head")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: overflow in .${e.className}`);
|
||||
for (const e of d.querySelectorAll(".table-container")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: a table scrolls by ${e.scrollWidth - e.clientWidth}px`);
|
||||
for (const e of d.querySelectorAll(".sp-h-name, .sp-h-value, .sp-h-sub")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: clipped "${e.textContent}"`);
|
||||
for (const i of d.querySelectorAll("i.ti")) if (win.getComputedStyle(i, "::before").content === "none") problems.add(`${at}: missing icon ${i.className}`);
|
||||
f.remove();
|
||||
}
|
||||
return { pages: urls.length, widths, textsChecked: texts, problems: [...problems] };
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backup · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Backup</h1>
|
||||
<p class="sp-lead">Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label" for="pw">Password</label><div class="control"><input class="input" id="pw" type="password" placeholder="at least 12 characters"></div></div>
|
||||
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
|
||||
</div>
|
||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="import">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
|
||||
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
||||
<div class="field"><label class="label">Domain export file</label>
|
||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
||||
<div class="field"><label class="label" for="ipw">Password</label><div class="control"><input class="input" id="ipw" name="import_password" type="password" autocomplete="off"></div>
|
||||
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Wrap every fragment in src/ with the panel shell and write it next to this file.
|
||||
|
||||
The mockups are static HTML, but thirty pages share one navbar; keeping that
|
||||
navbar in one place is the only reason this script exists. Run it from
|
||||
anywhere: python docs/assets/panel-redesign/panel/build.py
|
||||
|
||||
A fragment starts with one meta comment:
|
||||
<!-- title: Outbound domains | section: outbound | page: domains -->
|
||||
section/page pick the highlighted entries; "shell: bare" skips the navbar
|
||||
(sign-in, setup).
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
NAV = [
|
||||
("overview", "Overview", "overview.html", "ti-activity-heartbeat", []),
|
||||
("outbound", "Outbound", "out-domains.html", "ti-send", [
|
||||
("domains", "Domains", "out-domains.html", "ti-world-upload"),
|
||||
("log", "Log", "out-log.html", "ti-list-details"),
|
||||
("queue", "Queue", "out-queue.html", "ti-stack-2"),
|
||||
("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"),
|
||||
]),
|
||||
("inbound", "Inbound", "in-domains.html", "ti-inbox", [
|
||||
("domains", "Domains", "in-domains.html", "ti-world-download"),
|
||||
("log", "Log", "in-log.html", "ti-list-details"),
|
||||
("queue", "Queue", "in-queue.html", "ti-stack-2"),
|
||||
("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"),
|
||||
("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"),
|
||||
]),
|
||||
("server", "Server", "health.html", "ti-server-2", [
|
||||
("health", "Health", "health.html", "ti-heartbeat"),
|
||||
("preflight", "Preflight", "preflight.html", "ti-checklist"),
|
||||
("system-log", "System log", "system-log.html", "ti-file-text"),
|
||||
("backup", "Backup", "backup.html", "ti-archive"),
|
||||
("users", "Users", "users.html", "ti-users"),
|
||||
("settings", "Settings", "settings.html", "ti-settings"),
|
||||
]),
|
||||
]
|
||||
|
||||
HEAD = """<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
"""
|
||||
|
||||
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
|
||||
' · <a href="#">Source</a> · No warranty</footer>')
|
||||
|
||||
|
||||
def navbar(section, page):
|
||||
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
|
||||
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
|
||||
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
|
||||
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
|
||||
for key, label, href, _icon, pages in NAV:
|
||||
cur = " sp-current" if key == section else ""
|
||||
if not pages:
|
||||
out.append(f' <a class="navbar-item{cur}" href="{href}">{label}</a>')
|
||||
continue
|
||||
out.append(f' <div class="navbar-item has-dropdown is-hoverable{cur}">')
|
||||
out.append(f' <a class="navbar-link" href="{href}">{label}</a>')
|
||||
out.append(' <div class="navbar-dropdown">')
|
||||
for pkey, plabel, phref, picon in pages:
|
||||
act = " is-active" if key == section and pkey == page else ""
|
||||
out.append(f' <a class="navbar-item{act}" href="{phref}"><i class="ti {picon}"></i>{plabel}</a>')
|
||||
out.append(' </div>')
|
||||
out.append(' </div>')
|
||||
out += [' </div>', ' <div class="navbar-end">',
|
||||
' <div class="navbar-item has-dropdown is-hoverable%s">' % (" sp-current" if section == "user" else ""),
|
||||
' <a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>',
|
||||
' <div class="navbar-dropdown is-right">',
|
||||
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
|
||||
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
|
||||
' <hr class="navbar-divider">',
|
||||
# Signing out changes state, so it is a POST: a form with a button, not a link.
|
||||
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
|
||||
'<i class="ti ti-logout"></i>Sign out</button></form>',
|
||||
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
|
||||
'<div class="sp-perf" aria-hidden="true"></div>']
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def subnav(section, page):
|
||||
for key, _label, _href, _icon, pages in NAV:
|
||||
if key == section and pages:
|
||||
cur = ' aria-current="page"'
|
||||
links = "".join(
|
||||
f'<a href="{phref}"{cur if pkey == page else ""}><i class="ti {picon}"></i>{plabel}</a>'
|
||||
for pkey, plabel, phref, picon in pages)
|
||||
return f'<div class="sp-subnav"><div class="container">{links}</div></div>'
|
||||
return ""
|
||||
|
||||
|
||||
def build(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
m = re.match(r"\s*<!--(.*?)-->\s*", text, re.S)
|
||||
meta = dict(part.split(":", 1) for part in m.group(1).split("|"))
|
||||
meta = {k.strip(): v.strip() for k, v in meta.items()}
|
||||
body = text[m.end():]
|
||||
html = HEAD.format(title=meta["title"])
|
||||
if meta.get("shell") == "bare":
|
||||
html += f"<body>\n{body}\n</body>\n</html>\n"
|
||||
else:
|
||||
section, page = meta.get("section", ""), meta.get("page", "")
|
||||
html += (f"<body>\n{navbar(section, page)}\n{subnav(section, page)}\n<main>\n"
|
||||
f' <div class="container sp-page">\n{body}\n {FOOT}\n </div>\n</main>\n</body>\n</html>\n')
|
||||
(HERE / path.name).write_text(html, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pages = sorted((HERE / "src").glob("*.html"))
|
||||
for p in pages:
|
||||
build(p)
|
||||
print(f"built {len(pages)} pages")
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Design-contract lint for the panel mockups, and the source of outlines.json.
|
||||
|
||||
The contract (docs/plans/panel-redesign.md) is only worth something if a
|
||||
machine can say "no". This script is that machine for the mockups; the same
|
||||
rules are ported to a Go test over internal/web templates when the redesign is
|
||||
implemented, and that test compares rendered pages with outlines.json.
|
||||
|
||||
python docs/assets/panel-redesign/panel/check.py # lint
|
||||
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
|
||||
|
||||
Exit code 1 on any violation.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
VOID = {"input", "img", "br", "hr", "meta", "link"}
|
||||
|
||||
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
|
||||
# list is a design decision, not an implementation detail: add it here and to
|
||||
# the components page in the same change, or do without it.
|
||||
BULMA = set("""
|
||||
box button buttons checkbox column columns control field file file-cta file-input file-label
|
||||
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
|
||||
menu-list notification progress select table table-container tag textarea title
|
||||
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
|
||||
is-hidden is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
|
||||
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
|
||||
""".split())
|
||||
# Classes the shell (build.py, later layout.html) owns; screens never write them.
|
||||
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
|
||||
# The signed-out screens have no shell, so they carry their own footer.
|
||||
SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
|
||||
# Selectors allowed to set a width. Everything else gets its width from the
|
||||
# layout it sits in — that is the whole point of the redesign.
|
||||
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
|
||||
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf", ".sp-sheet")
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, tag, attrs, parent):
|
||||
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
return (self.attrs.get("class") or "").split()
|
||||
|
||||
def has(self, cls):
|
||||
return cls in self.classes
|
||||
|
||||
def walk(self):
|
||||
yield self
|
||||
for k in self.kids:
|
||||
yield from k.walk()
|
||||
|
||||
def find(self, cls):
|
||||
return [n for n in self.walk() if n.has(cls)]
|
||||
|
||||
|
||||
class Tree(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.root = Node("root", [], None)
|
||||
self.cur = self.root
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
n = Node(tag, attrs, self.cur)
|
||||
self.cur.kids.append(n)
|
||||
if tag not in VOID:
|
||||
self.cur = n
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
n = self.cur
|
||||
while n is not self.root and n.tag != tag:
|
||||
n = n.parent
|
||||
if n is not self.root:
|
||||
self.cur = n.parent
|
||||
|
||||
def handle_data(self, data):
|
||||
self.cur.text += data
|
||||
|
||||
|
||||
def parse(text):
|
||||
t = Tree()
|
||||
t.feed(text)
|
||||
return t.root
|
||||
|
||||
|
||||
def text_of(node):
|
||||
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
|
||||
|
||||
|
||||
def css_rules(css):
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
css = re.sub(r"@media[^{]*\{", "", css)
|
||||
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
|
||||
yield sel.strip(), body
|
||||
|
||||
|
||||
def box_kind(box):
|
||||
kinds = []
|
||||
for k in box.kids:
|
||||
if k.has("sp-box-head") or k.has("sp-box-foot"):
|
||||
continue
|
||||
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
|
||||
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
|
||||
if k.has(cls):
|
||||
kinds.append(name)
|
||||
break
|
||||
else:
|
||||
kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag)
|
||||
out = []
|
||||
for k in kinds:
|
||||
if not out or out[-1].split("×")[0] != k:
|
||||
out.append(k)
|
||||
else:
|
||||
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
|
||||
out[-1] = f"{k}×{n}"
|
||||
return "+".join(out)
|
||||
|
||||
|
||||
def outline(node):
|
||||
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
|
||||
out = []
|
||||
for k in node.kids:
|
||||
if k.has("sp-head"):
|
||||
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
|
||||
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
|
||||
parts.append("actions")
|
||||
out.append("+".join(parts))
|
||||
elif k.has("box"):
|
||||
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
|
||||
head = k.find("sp-box-head")
|
||||
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
|
||||
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
|
||||
elif k.has("columns"):
|
||||
cols = []
|
||||
for c in k.kids:
|
||||
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
|
||||
cols.append({size: outline(c)})
|
||||
out.append({"columns": cols})
|
||||
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
|
||||
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
|
||||
elif k.has("buttons"):
|
||||
out.append("submit_row")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
errors = []
|
||||
css = (HERE / "theme.css").read_text(encoding="utf-8")
|
||||
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
|
||||
|
||||
for sel, body in css_rules(css):
|
||||
if "!important" in body:
|
||||
errors.append(f"theme.css: !important in `{sel}`")
|
||||
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
|
||||
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
|
||||
# The type floor: 13px. Icons (.ti) and the postmark imprint are not running text.
|
||||
for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body):
|
||||
floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit]
|
||||
if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel:
|
||||
errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s")
|
||||
# `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped.
|
||||
sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body)
|
||||
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", sized) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
|
||||
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
|
||||
|
||||
used, documented, outlines = set(), set(), {}
|
||||
for src in sorted((HERE / "src").glob("*.html")):
|
||||
text = src.read_text(encoding="utf-8")
|
||||
name = src.stem
|
||||
bare = "shell: bare" in text.split("-->")[0]
|
||||
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
|
||||
if bad in text:
|
||||
errors.append(f"{name}: `{bad}` is forbidden in a screen")
|
||||
root = parse(text)
|
||||
for n in root.walk():
|
||||
for c in n.classes:
|
||||
if c.startswith("sp-"):
|
||||
(documented if name == "components" else used).add(c)
|
||||
if c in SHELL and not (bare and c in SIGNED_OUT):
|
||||
errors.append(f"{name}: `{c}` belongs to the shell")
|
||||
elif c not in defined:
|
||||
errors.append(f"{name}: `{c}` is not defined in theme.css")
|
||||
elif c == "ti" or c.startswith("ti-"):
|
||||
continue
|
||||
elif c == "is-small" and "progress" not in n.classes:
|
||||
errors.append(f"{name}: `is-small` on <{n.tag}> — controls keep the normal size; only a progress bar is small")
|
||||
elif c not in BULMA:
|
||||
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
|
||||
if bare:
|
||||
continue
|
||||
top = [k for k in root.kids if k.tag != "root"]
|
||||
if not top or not top[0].has("sp-head"):
|
||||
errors.append(f"{name}: the page must open with page_head (.sp-head)")
|
||||
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
|
||||
errors.append(f"{name}: exactly one <h1>")
|
||||
marks = root.find("sp-postmark")
|
||||
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
|
||||
errors.append(f"{name}: at most one postmark, inside page_head")
|
||||
for tbl in (n for n in root.walk() if n.tag == "table"):
|
||||
if not tbl.parent.has("table-container"):
|
||||
errors.append(f"{name}: a <table> must sit in a table-container so a wide one can scroll")
|
||||
for box in root.find("box"):
|
||||
if not box.kids or not box.kids[0].has("sp-box-head"):
|
||||
errors.append(f"{name}: a box must start with box_head")
|
||||
for k in top[1:]:
|
||||
# A flash is conditional, so it is allowed at the top level but never part of an outline.
|
||||
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
|
||||
if not ok:
|
||||
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
|
||||
if name != "components":
|
||||
outlines[name] = outline(root)
|
||||
|
||||
for c in sorted(used - documented - SIGNED_OUT):
|
||||
errors.append(f"components page does not show `{c}` — document it or stop using it")
|
||||
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
|
||||
for c in sorted(defined - used - documented - shell_used):
|
||||
errors.append(f"theme.css defines `{c}` but nothing uses it")
|
||||
|
||||
if "--write" in sys.argv and not errors:
|
||||
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
golden = HERE / "outlines.json"
|
||||
if golden.exists() and "--write" not in sys.argv:
|
||||
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
|
||||
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
|
||||
|
||||
for e in errors:
|
||||
print("FAIL", e)
|
||||
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,296 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Components · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Reference · page_head</p>
|
||||
<h1 class="title is-3">Components</h1>
|
||||
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
|
||||
</div>
|
||||
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
|
||||
</div>
|
||||
|
||||
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
|
||||
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
|
||||
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
|
||||
<div><dt>cards</dt><dd>page_head, then <code>columns sp-cards</code>: two <code>is-6</code> stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers: each box carries <code>data-order</code>, its own number, up to twelve. The two stacks under this box are laid out so. <a href="health.html">Example</a></dd></div>
|
||||
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
|
||||
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="buttons">
|
||||
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
|
||||
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
|
||||
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
|
||||
</div>
|
||||
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
|
||||
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
|
||||
<tr><td>error, fail, mismatch, bounced, reject, rate-limited</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
|
||||
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
|
||||
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
|
||||
<tr><td>a tag that opens a <code>sheet</code></td><td><button type="button" class="tag is-danger is-light" popovertarget="kit-sheet">reject</button><div class="sp-sheet" id="kit-sheet" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-danger is-light">reject</span><h2>sheet</h2><button type="button" popovertarget="kit-sheet" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>What it holds</th><td>What does not fit the row, as name and value</td></tr><tr><th>How it closes</th><td class="sp-mono sp-small">Esc<br>a click outside<br>the cross</td></tr></tbody></table></div></div></div></div></td><td class="sp-mono sp-small">button.tag + sp-sheet (popover)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
|
||||
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
|
||||
</div>
|
||||
<div class="box sp-danger-zone">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
|
||||
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
|
||||
</div>
|
||||
<div class="box sp-credential">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
|
||||
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
|
||||
<div class="sp-health">
|
||||
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Verdict in words</span><span class="sp-h-sub">ok · one supporting fact</span></span></a>
|
||||
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">sp-warn</span></span></a>
|
||||
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">1 of 2 answering</span><span class="sp-h-sub">sp-fail</span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
|
||||
<div class="field"><label class="label">In DNS now</label>
|
||||
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
|
||||
<p class="help is-danger">What is wrong and the exact fix.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
|
||||
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
|
||||
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
|
||||
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Label</dt><dd>Value</dd></div>
|
||||
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
|
||||
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
|
||||
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
|
||||
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">side_menu</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
|
||||
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Rarely changed</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="sp-form">
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
|
||||
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
|
||||
</div>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
|
||||
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
|
||||
</div>
|
||||
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">13</span><h2>shell</h2><div class="sp-end">Owned by the layout — a screen never writes it</div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Name</dt><dd>The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.</dd></div>
|
||||
<div><dt>Edge</dt><dd>Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, <code>perf-edge.svg</code>.</dd></div>
|
||||
<div><dt>Sibling strip</dt><dd>The pages of the current group, under the edge. Five or six entries; past that the group is split.</dd></div>
|
||||
<div><dt>Menus</dt><dd>Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">14</span><h2>Type scale</h2><div class="sp-end">Three sizes · nothing to be read is under 13px</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
|
||||
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td><span class="sp-kicker">Upper-case mono label</span> <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,110 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>shop.example.org · DMARC reports · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
|
||||
<h1 class="title is-3">shop.example.org</h1>
|
||||
<p class="sp-lead">11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening <code>p=</code>.</p>
|
||||
</div>
|
||||
<a class="button" href="out-domain.html"><i class="ti ti-world-upload"></i>Domain page</a>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>11 204</td><td>0</td><td><span class="tag is-success is-light">none</span></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>0</td><td>41</td><td><span class="tag is-warning is-light">none</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 04:38</td><td class="sp-muted">Outlook.com</td><td>19 Sep</td><td>1 877</td><td><span class="tag is-warning is-light">34</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,119 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Outlook.com · 20 Sep · DMARC · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
|
||||
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
|
||||
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Published policy</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>p / sp / pct</dt><dd class="sp-mono">none / none / 100</dd></div>
|
||||
<div><dt>adkim / aspf</dt><dd class="sp-mono">r / r</dd></div>
|
||||
<div><dt>rua</dt><dd class="sp-mono sp-small">dmarc@mail.example.org</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Records</h2><div class="sp-end">Parsed from the aggregate XML</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>1 903</td><td><span class="tag is-success is-light">none</span></td><td><span class="tag is-success is-light">pass</span></td><td><span class="tag is-success is-light">pass</span></td><td>shop.example.org</td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>7</td><td><span class="tag is-warning is-light">none</span></td><td><span class="tag is-danger is-light">fail</span></td><td><span class="tag is-danger is-light">fail</span></td><td>shop.example.org</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DMARC reports · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Outbound</p>
|
||||
<h1 class="title is-3">DMARC reports</h1>
|
||||
<p class="sp-lead">How receivers saw mail claiming to be from your domains. Aggregate (<code>rua=</code>) only; open a report for the parsed XML.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 23:58</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">Yahoo</td><td>19 Sep</td><td>38</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 06:09</td><td><a href="dmarc-domain.html">notify.acme.io</a></td><td class="sp-muted">google.com</td><td>19 Sep</td><td>61</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,179 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Health · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Server</p>
|
||||
<h1 class="title is-3">Health</h1>
|
||||
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
|
||||
</div>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
|
||||
<div class="columns sp-cards">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="processes" data-order="1">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
|
||||
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
|
||||
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="sender-check" data-order="3">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sender check</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#sender-check" title="What is checked and what these numbers are for"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Period</th><th>Panel not answering</th><th>Connections turned away</th><th>Messages deferred</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Today</td><td>0 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 30 days</td><td>4 s</td><td>0</td><td>0</td></tr>
|
||||
<tr><td>Last 12 months</td><td>≈ 41 s</td><td>3</td><td>1</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">The panel holds each message's <code>From:</code> against the application that sends it. While the panel does not answer, Postfix answers the sending application with a temporary error instead of accepting mail unchecked, and the application tries again later. Time is counted only while Postfix itself was running; ≈ marks a period with a start at which that could not be asked, and its time is at most what is shown. Counted since <span class="sp-mono">2026-10-13</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="hostname" data-order="5">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="machine" data-order="7">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="sockets" data-order="2">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· applications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>dkim-signer <span class="sp-muted">· notifications</span><br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sign-local.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>sender-check<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/sender.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="box" id="certificate" data-order="4">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="notification-mail" data-order="6">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="configuration" data-order="8">
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,161 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Help · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Reference</p>
|
||||
<h1 class="title is-3">Help</h1>
|
||||
<p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">Overview</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
|
||||
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
|
||||
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Outbound</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
|
||||
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
|
||||
<li><a href="#sender-check"><i class="ti ti-shield-check"></i>Sender check</a></li>
|
||||
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
|
||||
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
|
||||
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
|
||||
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Inbound</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Valid recipients</a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
|
||||
<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>
|
||||
<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Server</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
|
||||
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></div>
|
||||
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
|
||||
<div class="sp-help-topic" id="sender-check"><h3>Sender check</h3><p>An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The <code>From:</code> line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other <code>From:</code> - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with <code>550 5.7.1</code>. The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.<br><br>The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own.</p></div>
|
||||
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</p></div>
|
||||
<div class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and <code>mail.log</code> never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a <code>From:</code> line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains.</p></div>
|
||||
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
|
||||
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
|
||||
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
|
||||
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it; Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
|
||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or refused with a temporary error, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,101 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Delete lists.example.org · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> / <a href="in-domain.html">lists.example.org</a> /</p>
|
||||
<h1 class="title is-3">Delete lists.example.org</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<div class="box sp-danger-zone">
|
||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
|
||||
<ul class="sp-list-check">
|
||||
<li><i class="ti ti-x"></i>stop accepting mail for it on port 25 — senders get a relay-denied reply;</li>
|
||||
<li><i class="ti ti-x"></i>remove its upstream and its 4 listed recipients.</li>
|
||||
</ul>
|
||||
<p class="sp-muted mb-4">Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.</p>
|
||||
<form method="post" action="in-domains.html">
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete lists.example.org</button>
|
||||
<a class="button is-text" href="in-domain.html">Keep it</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,171 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>lists.example.org · Inbound domains · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-postmark"><span>DNS</span><b>OK</b><span>MX POINTS HERE</span></div>
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
|
||||
<h1 class="title is-3">lists.example.org</h1>
|
||||
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<aside class="menu sp-side">
|
||||
<p class="menu-label">This domain</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#dns"><i class="ti ti-world-check"></i>MX record <span class="tag is-success is-light">ok</span></a></li>
|
||||
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
|
||||
<li><a href="#recipients"><i class="ti ti-address-book"></i>Recipients <span class="tag is-light">4</span></a></li>
|
||||
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter <span class="tag is-success is-light">on</span></a></li>
|
||||
</ul>
|
||||
<p class="menu-label">See also</p>
|
||||
<ul class="menu-list">
|
||||
<li><a href="in-log.html"><i class="ti ti-list-details"></i>Log for this domain</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Rarely changed</p>
|
||||
<ul class="menu-list">
|
||||
<li><a class="has-text-danger" href="in-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="column is-9">
|
||||
<div class="box" id="dns">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="MX"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="upstream">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label" for="host">Host</label><div class="control"><input class="input sp-mono" id="host" name="host" value="mx.internal" autocomplete="off" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label" for="tls">TLS to upstream</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
|
||||
<button type="submit" class="button is-primary">Save upstream</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="recipients">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="field"><label class="label" for="rmode">Who to accept</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="recipient_mode"><option selected>Listed addresses only</option><option>Any recipient at this domain</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="addresses">Addresses</label>
|
||||
<div class="control"><textarea class="textarea sp-mono" id="addresses" name="addresses" rows="4">announce@lists.example.org
|
||||
dev@lists.example.org
|
||||
owner@lists.example.org
|
||||
security@lists.example.org</textarea></div>
|
||||
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
|
||||
<button type="submit" class="button is-primary">Save recipients</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="filter">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
|
||||
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
|
||||
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
|
||||
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Inbound domains · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound</p>
|
||||
<h1 class="title is-3">Inbound domains</h1>
|
||||
<p class="sp-lead">Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add a domain</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="in-domain.html">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
|
||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
|
||||
</div>
|
||||
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
|
||||
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,145 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Filter lists · SelfPost</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
||||
<link rel="stylesheet" href="../shared/brand.css">
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-primary" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="overview.html">Overview</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="out-domains.html">Outbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
|
||||
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||
<a class="navbar-link" href="in-domains.html">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
|
||||
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
|
||||
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
|
||||
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
|
||||
<a class="navbar-item is-active" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="health.html">Server</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
||||
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
|
||||
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
|
||||
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
||||
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||
<hr class="navbar-divider">
|
||||
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sp-perf" aria-hidden="true"></div>
|
||||
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html" aria-current="page"><i class="ti ti-filter"></i>Filter lists</a></div></div>
|
||||
<main>
|
||||
<div class="container sp-page">
|
||||
<div class="sp-head">
|
||||
<div class="sp-grow">
|
||||
<p class="sp-kicker">Inbound · global role</p>
|
||||
<h1 class="title is-3">Filter lists</h1>
|
||||
<p class="sp-lead">Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#filter-lists" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="#">
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="list">List</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type"><option>Deny — always turn away</option><option>Allow — always let through</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client address or network</option></select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
<div class="control"><input class="input sp-mono" id="value" name="value" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label class="label" for="note">Note</label>
|
||||
<div class="control"><input class="input" id="note" name="note" placeholder="Why — for whoever reads this list next year"></div></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">bulk.example</td><td><button type="button" class="tag is-light" popovertarget="deny-1">domain</button><div class="sp-sheet" id="deny-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>bulk.example</h2><button type="button" popovertarget="deny-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>newsletter nobody subscribed to</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-02</td></tr><tr><th>Matched</th><td>41 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:57</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">41</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">198.51.100.0/24</td><td><button type="button" class="tag is-light" popovertarget="deny-2">ip</button><div class="sp-sheet" id="deny-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">ip</span><h2>198.51.100.0/24</h2><button type="button" popovertarget="deny-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>dictionary attack on lists.example.org</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-08-19</td></tr><tr><th>Matched</th><td>1203 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 09:12</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">1203</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">win@lottery.example</td><td><button type="button" class="tag is-light" popovertarget="deny-3">address</button><div class="sp-sheet" id="deny-3" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>win@lottery.example</h2><button type="button" popovertarget="deny-3" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>—</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-07-30</td></tr><tr><th>Matched</th><td>never in the last 90 days</td></tr></tbody></table></div></div></div></div></td><td class="sp-muted sp-nowrap">never</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Kind</th><th>Matched</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-small">partner.example</td><td><button type="button" class="tag is-light" popovertarget="allow-1">domain</button><div class="sp-sheet" id="allow-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">domain</span><h2>partner.example</h2><button type="button" popovertarget="allow-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>invoices were scored as spam</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-09-11</td></tr><tr><th>Matched</th><td>7 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-19 16:40</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">7</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
<tr><td class="sp-mono sp-small">noreply@github.example</td><td><button type="button" class="tag is-light" popovertarget="allow-2">address</button><div class="sp-sheet" id="allow-2" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-light">address</span><h2>noreply@github.example</h2><button type="button" popovertarget="allow-2" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Note</th><td>security advisories</td></tr><tr><th>Added</th><td class="sp-mono sp-small">2026-06-04</td></tr><tr><th>Matched</th><td>18 in the last 90 days</td></tr><tr><th>Last match</th><td class="sp-mono sp-small">2026-09-21 13:30</td></tr></tbody></table></div></div></div></div></td><td class="sp-nowrap">18</td><td class="sp-actions"><form method="post" action="#"><button type="submit" class="has-text-danger">Remove</button></form></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||