Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff7514621 | ||
|
|
ae7f5258a6 | ||
|
|
5bd632c8e0 | ||
|
|
f1074eb526 | ||
|
|
bc005dea85 | ||
|
|
4b2f3880ef | ||
|
|
fae930a37a | ||
|
|
c443149623 | ||
|
|
5eaf665c01 | ||
|
|
7cc499d57c | ||
|
|
aeba3f8db1 | ||
|
|
d6e67ceb61 | ||
|
|
8ed20b0c98 | ||
|
|
bd7a3b123d | ||
|
|
669f928b4d | ||
|
|
f44f5333b3 | ||
|
|
652f1fe438 | ||
|
|
c9d726dd64 | ||
|
|
f59befdb00 | ||
|
|
15baa1e5d0 | ||
|
|
c9076655b9 | ||
|
|
e34d81498e | ||
|
|
155b721438 | ||
|
|
efaf016c5f | ||
|
|
2bdc0ea9a8 | ||
|
|
6c8bf0d3b3 | ||
|
|
c713f51f24 | ||
|
|
0ed36e9624 | ||
|
|
5178f80fdf | ||
|
|
10c41677bd | ||
|
|
01c754f857 | ||
|
|
691d3c255f | ||
|
|
295d1afb7d | ||
|
|
06c2014384 | ||
|
|
a4cfa11323 | ||
|
|
236cb07769 | ||
|
|
04993e0da3 | ||
|
|
7eb168f418 | ||
|
|
a1b6209470 | ||
|
|
44683a4996 | ||
|
|
870012514a | ||
|
|
012802d83d | ||
|
|
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,9 +4,6 @@
|
||||
.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
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/sh
|
||||
# base-packages.sh: compare the Debian packages the mail path runs on with
|
||||
# what debian:trixie-slim would install today (security audit 2026-10-17,
|
||||
# I5). The image installs without pinning, and published tags are never
|
||||
# rebuilt, so a security update of postfix, Cyrus SASL, OpenSSL or Berkeley
|
||||
# DB reaches operators only with the next release. This fails when one of
|
||||
# them has moved past the version the last release was built with - the
|
||||
# table between the base-package-versions markers in docs/development.md -
|
||||
# as the nudge to cut a patch release (and then to update the table).
|
||||
#
|
||||
# Run by .github/workflows/base-packages.yml every week; needs docker.
|
||||
set -eu
|
||||
doc=docs/development.md
|
||||
table=$(sed -n '/<!-- base-package-versions -->/,/<!-- \/base-package-versions -->/p' "$doc" |
|
||||
sed -n 's/^| `\([^`]*\)` | `\([^`]*\)` |$/\1 \2/p')
|
||||
[ -n "$table" ] || { echo "no base-package-versions table in $doc" >&2; exit 2; }
|
||||
names=$(echo "$table" | cut -d' ' -f1 | tr '\n' ' ')
|
||||
# apt-cache policy's "Candidate:" is what an install would take now.
|
||||
now=$(docker run --rm debian:trixie-slim sh -c "apt-get update -qq >/dev/null && for p in $names; do printf '%s %s\n' \"\$p\" \"\$(apt-cache policy \"\$p\" | sed -n 's/^ *Candidate: //p')\"; done")
|
||||
moved=0
|
||||
echo "$table" | while read -r name built; do
|
||||
cand=$(echo "$now" | awk -v n="$name" '$1 == n { print $2 }')
|
||||
if [ "$cand" != "$built" ]; then
|
||||
echo "::warning::$name: the last release was built with $built, trixie now installs ${cand:-nothing}" >&2
|
||||
echo moved
|
||||
fi
|
||||
done | grep -q moved && moved=1
|
||||
if [ "$moved" = 1 ]; then
|
||||
echo "A package the mail path runs on has moved: cut a patch release, then update the table in $doc." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Every watched package is as the last release was built."
|
||||
@@ -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,20 +0,0 @@
|
||||
name: base packages
|
||||
|
||||
# Every Monday: has a Debian package the mail path runs on moved since the
|
||||
# last release was built? A red run is the nudge to cut a patch release
|
||||
# (security audit 2026-10-17, I5; .github/scripts/base-packages.sh).
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 5 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
watch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Compare with trixie
|
||||
run: sh .github/scripts/base-packages.sh
|
||||
@@ -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
|
||||
@@ -66,14 +48,12 @@ jobs:
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: v${{ needs.prepare.outputs.version }}
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (native, loaded locally for the e2e gate)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: build/Dockerfile
|
||||
@@ -83,49 +63,16 @@ jobs:
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
tags: selfpost:e2e
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
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
|
||||
|
||||
# The binaries about to be pushed, as built in the image - with the Go
|
||||
# of golang:1.26-trixie, which may lag the runner's: a standard-library
|
||||
# advisory fixed in a later patch fails the release until the base tag
|
||||
# moves, which is the point (security audit 2026-10-17, I4).
|
||||
- name: Vulnerabilities in the image's binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
id=$(docker create selfpost:e2e)
|
||||
trap 'docker rm -f "$id" >/dev/null' EXIT
|
||||
for bin in panel selfpost-backup; do
|
||||
docker cp "$id:/usr/local/bin/$bin" "/tmp/$bin"
|
||||
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "/tmp/$bin"
|
||||
done
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -166,14 +113,10 @@ jobs:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -209,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"
|
||||
|
||||
@@ -5,33 +5,16 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# The job reads the repository and nothing else (security audit 2026-10-17,
|
||||
# review of stages 2-6).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
# design-first.sh reads each commit's parent and the template history.
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
test/e2e/go.sum
|
||||
|
||||
- name: Format
|
||||
run: |
|
||||
@@ -45,31 +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 ./...
|
||||
|
||||
# Known vulnerabilities in the module's dependencies and in the
|
||||
# standard library of this Go, reached from SelfPost's code (security
|
||||
# audit 2026-10-17, I4: twelve stdlib advisories went unnoticed until
|
||||
# the base image's tag happened to move). Pinned like staticcheck.
|
||||
- name: Vulnerabilities
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
|
||||
|
||||
# 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
|
||||
|
||||
@@ -10,8 +10,8 @@ Corresponding Source for the published releases is at:
|
||||
|
||||
If you modify SelfPost and let users interact with it over a network,
|
||||
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.
|
||||
your modified version. Update the Source link in the panel footer
|
||||
(internal/web/view/templates/layout.html) so it points at your sources.
|
||||
|
||||
Third-party software included in this distribution
|
||||
----------------------------------------------------
|
||||
@@ -21,28 +21,9 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||
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:
|
||||
|
||||
@@ -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 and a small Go panel, which also signs outgoing mail with DKIM, 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 submitting mail (optional; refused at MAIL FROM, not an authentication boundary)
|
||||
- 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 per-application 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
|
||||
|
||||
@@ -44,7 +37,7 @@ send log and DNS checks in the panel, encrypted backups.
|
||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||
| [Roadmap](docs/roadmap.md) | Open work — direction, not commitments |
|
||||
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||
|
||||
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||
@@ -62,23 +55,9 @@ missing PTR record for you. Details: [Operator guide](docs/guide.md).
|
||||
|
||||
- Docker + Compose v2 on the host
|
||||
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
|
||||
- Machine, by what is switched on (measured; details and disk growth in the
|
||||
guide's [Machine requirements](docs/guide.md#machine-requirements)):
|
||||
|
||||
| Switched on | Host memory, at least | Images on disk |
|
||||
|---|---|---|
|
||||
| Outbound only (465/587), with or without port 25 (inbound relay, DMARC reports) | 512 MB | 315 MB |
|
||||
| With the spam filter (rspamd sidecar), port-25 messages kept small | 768 MB | 626 MB |
|
||||
| With the spam filter and 25 MiB messages, several at once | 1 GB | 626 MB |
|
||||
|
||||
rspamd takes about 100 MB more for each large message it scans; a reverse
|
||||
proxy in a container adds its own few megabytes.
|
||||
|
||||
One vCPU is enough. **Building the image needs 1.5 GB of memory and about
|
||||
3 GB of free disk** (the Go compiler: a 1 GB build was killed) - on a
|
||||
smaller machine, build elsewhere and `docker load` the image. The send log
|
||||
is what grows: about 2-3 KB a message for the retention (90 days by
|
||||
default) - 10 000 messages a day keep about 2.5 GB.
|
||||
- Rough sizing: **1 vCPU**, **512 MB–1 GB RAM**, **8–10 GB disk** (send log and
|
||||
rotated `mail.log` are the main growth drivers; both sit in the `./data`
|
||||
volume, and both are capped — 90 days and 14 files by default)
|
||||
|
||||
### Network and IP
|
||||
|
||||
@@ -97,53 +76,28 @@ 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.16.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.16.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.16.0
|
||||
ghcr.io/mixeme/selfpost:1.2.0
|
||||
```
|
||||
|
||||
This trial runs with Docker's default capabilities and no
|
||||
`no-new-privileges`; the compose file of a real deployment
|
||||
(`deploy/docker-compose.yml`) drops every capability but the few it names,
|
||||
sets `no-new-privileges` and a process ceiling.
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
```sh
|
||||
@@ -175,19 +129,136 @@ 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
|
||||
|
||||
|
||||
@@ -3,22 +3,15 @@
|
||||
## Supported versions
|
||||
|
||||
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||
2.x line** only; there is no backporting to earlier minors or to 1.x. Upgrade before
|
||||
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 2.x | yes |
|
||||
| earlier 2.x | no — upgrade first |
|
||||
| 1.x | no — upgrade to 2.x (guide § Upgrading) |
|
||||
| latest 1.x | yes |
|
||||
| earlier 1.x | no — upgrade first |
|
||||
| 0.x | no (pre-release) |
|
||||
|
||||
A security update of a Debian package the image runs - Postfix, Cyrus SASL,
|
||||
OpenSSL, Berkeley DB - arrives with the next patch release built after it:
|
||||
published image tags are never rebuilt. A weekly check
|
||||
(`.github/workflows/base-packages.yml`) says when one has moved, and a patch
|
||||
release follows.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||
@@ -77,8 +70,7 @@ These are the operator's responsibility or accepted trade-offs, documented in
|
||||
- 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, the Go libraries SelfPost builds on,
|
||||
or the base image — report
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
# 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. third_party/go-milter is a module
|
||||
# go.mod replaces go-milter with (third_party/go-milter/PATCHES.md): the
|
||||
# download needs it.
|
||||
# Module metadata first for layer caching.
|
||||
COPY go.mod go.sum ./
|
||||
COPY third_party ./third_party
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
@@ -33,172 +30,89 @@ 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/architecture.md § DKIM signing).
|
||||
# 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.
|
||||
# tzdata is named for TZ (local-time): the zone of every time the panel shows
|
||||
# and of the stamps Postfix writes in mail.log. supervisor's python3.13 brings
|
||||
# it today (apt-cache rdepends --installed tzdata in the image, 2026-10-17);
|
||||
# a TZ the image did not have would leave both in UTC without a word.
|
||||
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 \
|
||||
tzdata \
|
||||
&& 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 saw it: until 2.14.4 an operator gave the
|
||||
# TLS private key on the host to this group by number - now to backupkey,
|
||||
# below, which Postfix is not in - and a key given then still names it. 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 host's TLS private key is given to this group, not to selfpost, so that
|
||||
# the panel can put it into a full backup (guide § Taking a full backup) and
|
||||
# no Postfix daemon can read it: postfix is in selfpost, and a memory-safety
|
||||
# bug in an smtpd reached from the internet would otherwise yield the key of
|
||||
# the mail and of the panel (security audit 2026-10-17, I1). Postfix itself
|
||||
# serves a root-only copy (postfix-config.sh). The panel is the one member.
|
||||
# Fixed for the same reason as selfpost's, next to it.
|
||||
RUN groupadd --system --gid 10997 backupkey \
|
||||
&& usermod -aG backupkey panel
|
||||
|
||||
# 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
|
||||
# serves the same text at /license from an embedded copy.
|
||||
COPY LICENSE NOTICE /usr/share/doc/selfpost/
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
# 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"
|
||||
)
|
||||
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()
|
||||
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,88 +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/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/postfix/dmarc_recipients /data/postfix/dmarc_transport /data/postfix/dmarc_relay_domains \
|
||||
/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.
|
||||
# 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 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). The selector table is written by the panel before its signer
|
||||
# serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
|
||||
# /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
|
||||
# first start and kept the old tree for a rollback to 2.7.x until the next
|
||||
# release, this one (CHANGELOG [2.9.0]). It is removed
|
||||
# once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
|
||||
# old tree only - stops the start instead: the panel would make the server's
|
||||
# own name a new key, and its published record would no longer match; 2.8.x
|
||||
# copies them (guide § Upgrading). rm -rf removes a link, not what it points
|
||||
# to, and follows none inside (GNU coreutils rm(1)); a link at the path is
|
||||
# gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.)
|
||||
has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; }
|
||||
if [ -d /data/opendkim ]; then
|
||||
if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
|
||||
echo "entrypoint: the DKIM keys are only under /data/opendkim, as 2.7.x kept them; start 2.8.x once first - it copies them to /data/dkim - then this image (guide, § Upgrading)" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /data/opendkim
|
||||
echo "entrypoint: removed /data/opendkim, the key tree of 2.7.x; the keys are under /data/dkim" >&2
|
||||
fi
|
||||
rm -rf /data/dkim.new
|
||||
mkdir -p /data/dkim/keys
|
||||
chown -R panel:panel /data/dkim
|
||||
find /data/dkim -type d -exec chmod 0700 {} + -exec chmod g-s {} +
|
||||
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
|
||||
@@ -142,118 +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
|
||||
[ -e /data/postfix/sender_login_maps ] || [ -L /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" ] || [ -L "/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
|
||||
# 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
|
||||
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 ] || [ -L /data/log/mail.log ] || : > /data/log/mail.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
|
||||
|
||||
# The queue tree belongs to Postfix's users by name. Their numbers are what
|
||||
# the package's install hands out and depend on what else the image installs:
|
||||
# without opendkim (2.8.0) postfix went from uid 101 to 100 and postdrop from
|
||||
# gid 103 to 102, and a queue of an earlier image kept the old numbers. So an
|
||||
# existing tree is handed over before postfix-config.sh runs "postfix check",
|
||||
# whose postsuper drops to the mail owner before it reads the queue and
|
||||
# otherwise stops the start ("scan_dir_push: open directory defer: Permission
|
||||
# denied", the test server, 2026-10-16; Postfix 3.10.13,
|
||||
# src/postsuper/postsuper.c:1442, set_ugid(var_owner_uid, var_owner_gid);
|
||||
# conf/postfix-script:342). After the check it is done again for a tree the
|
||||
# check has just built.
|
||||
own_queue() {
|
||||
[ -d /data/postfix/queue/active ] || return 0
|
||||
chown -R postfix:postfix /data/postfix/queue
|
||||
# The recursive chown 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
|
||||
}
|
||||
own_queue
|
||||
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
|
||||
@@ -261,27 +144,4 @@ own_queue
|
||||
# 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 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).
|
||||
own_queue
|
||||
|
||||
# [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
|
||||
@@ -15,15 +12,7 @@
|
||||
compress
|
||||
delaycompress
|
||||
create 0640 postfix selfpost
|
||||
# A rotation due when the container starts runs before Postfix does
|
||||
# (logrotate-loop.sh runs at once, the wrapper waits for the milter
|
||||
# sockets): no postlogd holds the old file then, the one Postfix starts
|
||||
# opens the new, and a reload would only fail - "fatal: the Postfix mail
|
||||
# system is not running", seen at the test server's rollout of
|
||||
# 2026-10-16. "postfix status" exits 1 while no master runs (Postfix
|
||||
# 3.10.13, conf/postfix-script:297-307), reload then nothing.
|
||||
postrotate
|
||||
/usr/sbin/postfix status >/dev/null 2>&1 || exit 0
|
||||
/usr/sbin/postfix reload
|
||||
endscript
|
||||
}
|
||||
|
||||
@@ -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,95 +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.x of bookworm, before trixie's 3.5
|
||||
# (re-read on 3.5.7 is due with the next look at this script): 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 10997 (the panel's alone) 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,84 +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.
|
||||
# The client trusts the authorities Debian's ca-certificates ships, for an
|
||||
# inbound domain's upstream with Verified TLS (the "secure" level in the TLS
|
||||
# policy map, upstream-tls-verify): Postfix trusts none by default - the
|
||||
# *_tls_CAfile defaults are empty and tls_append_default_CA is "no" (Postfix
|
||||
# 3.10.13, src/global/mail_params.h, DEF_SMTP_TLS_CA_FILE,
|
||||
# DEF_TLS_APPEND_DEF_CA). The levels may and encrypt check nothing and only
|
||||
# log the verdict; mail to the recipients' MX is unchanged. The file is the
|
||||
# bundle update-ca-certificates writes (ca-certificates, installed in the
|
||||
# image), read by smtp(8) unchrooted (chroot is off below).
|
||||
# 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" \
|
||||
"smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt"
|
||||
"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}" \
|
||||
@@ -323,40 +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").
|
||||
# ETRN is refused to everyone and not offered: Postfix's default lets any
|
||||
# client flush the queue toward an inbound domain's upstream
|
||||
# (fast_flush_domains = $relay_domains), and nothing here needs it (security
|
||||
# audit 2026-10-17, S6; Postfix 3.10.13, src/smtpd/smtpd.c, etrn_cmd() asks
|
||||
# smtpd_check_etrn(), whose "reject" answers reject_code, 554). The discard is
|
||||
# silent: without the pseudo keyword smtpd logs "discarding EHLO keywords:
|
||||
# ETRN" for every connection (src/smtpd/smtpd.c:2016, EHLO_MASK_SILENT;
|
||||
# src/global/ehlo_mask.c:88; seen on the test server after 2.14.4).
|
||||
postconf -e \
|
||||
"smtpd_tls_protocols=>=TLSv1.2" \
|
||||
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
|
||||
"disable_vrfy_command=yes" \
|
||||
"smtpd_etrn_restrictions=reject" \
|
||||
"smtpd_discard_ehlo_keywords=silent-discard,etrn" \
|
||||
'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" \
|
||||
@@ -366,175 +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/architecture.md § DKIM signing). 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"
|
||||
|
||||
# Long, non-repeating queue ids (owner, 2026-10-16). The panel finds a
|
||||
# message's rows, its stored lines and the inbound journal's upstream result
|
||||
# by the queue id of mail.log; a short id is the microsecond of arrival and
|
||||
# the queue file's inode in hex and repeats once the inode comes round, so a
|
||||
# later message could meet an earlier one's rows. A long id adds the time in
|
||||
# seconds and does not repeat. Postfix 3.10.13, src/global/mail_params.h:1010,
|
||||
# DEF_LONG_QUEUE_IDS 0; src/global/mail_queue.h, MQID_SH_* and MQID_LG_*;
|
||||
# proto/postconf.proto, enable_long_queue_ids: "Enable long, non-repeating,
|
||||
# queue IDs", existing queue files keep their names, postqueue -p gets a
|
||||
# wider Queue ID column. Every pattern of the panel takes both shapes
|
||||
# (internal/logtail, internal/postfix/queue.go).
|
||||
postconf -e "enable_long_queue_ids=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"
|
||||
@@ -542,302 +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
|
||||
# [ -e ] is false for a dangling link and the redirect would follow
|
||||
# it: the entrypoint's drop_links removes links at these paths, and
|
||||
# this refuses to create through one all the same (audit 2026-10-17,
|
||||
# I2).
|
||||
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||
[ -e "$f" ] || [ -L "$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" ] || [ -L "$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 (docs/security.md § Mail path: 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
|
||||
@@ -847,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,144 +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, cmd/selfpost-backup 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_]+)"`)
|
||||
// A name kept in a constant, as the backup CLI keeps its password's.
|
||||
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
|
||||
)
|
||||
|
||||
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("..", "selfpost-backup"), 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
|
||||
}
|
||||
text := readFile(t, path)
|
||||
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
for _, m := range goEnvConst.FindAllStringSubmatch(text, -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)
|
||||
// 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 documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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+")")
|
||||
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)
|
||||
}
|
||||
}
|
||||
slices.Sort(stale)
|
||||
for _, key := range stale {
|
||||
t.Errorf("%s is documented but nothing reads it", key)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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,163 +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 {
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
||||
// service delegates to them when a domain (and its applications) is deleted.
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, domain.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,
|
||||
}
|
||||
}
|
||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||
|
||||
// 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{
|
||||
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,
|
||||
DeployRoot: cfg.deployRoot,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
||||
SMTPBanner: cfg.smtpBanner,
|
||||
TimeZone: cfg.timeZone,
|
||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
||||
SignSocket: cfg.signSocket,
|
||||
SignLocalSocket: cfg.signLocalSocket,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
InboundJournalSocket: cfg.inboundJournalSocket,
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,12 +47,14 @@ 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.
|
||||
shutDown := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutDown)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -183,13 +65,5 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
// ListenAndServe returns at the stop, before its requests have
|
||||
// (net/http, Server.Shutdown); Shutdown returns once they have. The links
|
||||
// they mail after their answers go on after that, and end before the
|
||||
// database closes.
|
||||
<-shutDown
|
||||
drainCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
srvApp.Drain(drainCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// the rate-limit checks.
|
||||
//
|
||||
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -19,33 +20,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,11 +50,6 @@ 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
|
||||
|
||||
@@ -85,48 +63,20 @@ type config struct {
|
||||
trustedProxies []*net.IPNet
|
||||
sessionIdleDays int
|
||||
dnsResolvers []string
|
||||
rateLimitMessagesPerIP int
|
||||
rateLimitWindowSeconds int
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
// smtpBanner is Postfix's smtpd_banner, expanded, read from postconf at
|
||||
// the start: Health shows the greeting it makes.
|
||||
smtpBanner string
|
||||
// timeZone is TZ as the container was started with ("" for none): the
|
||||
// zone of every time and day the panel shows, which Settings names.
|
||||
timeZone 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 {
|
||||
@@ -134,11 +84,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.
|
||||
@@ -153,7 +98,6 @@ func loadConfig() config {
|
||||
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||
timeZone: os.Getenv("TZ"),
|
||||
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
@@ -173,16 +117,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"),
|
||||
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
// 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 §
|
||||
@@ -193,45 +133,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
|
||||
@@ -276,17 +183,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)
|
||||
@@ -299,120 +199,39 @@ 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")
|
||||
// A link taken after the backup was made would work again from it
|
||||
// (security review, 2026-10-17); a link sent before a restore is
|
||||
// asked for again.
|
||||
if err := st.DeleteAllUserLinks(); err != nil {
|
||||
_ = st.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
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 roles (the list below) and blocks until a shutdown
|
||||
// signal or the first fatal error from any role. A signal triggers a clean stop of all
|
||||
// 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
|
||||
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
||||
// 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()
|
||||
cfg.smtpBanner = health.SMTPBanner()
|
||||
|
||||
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
|
||||
@@ -420,30 +239,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,514 +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. The account
|
||||
// password goes with it, as the form asks for it (backup-reauth).
|
||||
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
|
||||
t.Helper()
|
||||
form := url.Values{"account_password": {restorePassword}}
|
||||
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,197 +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/architecture.md § Sender check).
|
||||
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 day of the
|
||||
// container's zone.
|
||||
type dayPart struct {
|
||||
day time.Time
|
||||
down time.Duration
|
||||
}
|
||||
|
||||
// byDay cuts the time from 'from' to 'to' at the midnights of the container's
|
||||
// zone 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.Local(), to.Local()
|
||||
var parts []dayPart
|
||||
for from.Before(to) {
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.Local).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,76 +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)
|
||||
}
|
||||
}
|
||||
|
||||
// In a container with TZ=Europe/Moscow a day is Moscow's: an outage from
|
||||
// 20:50 to 21:10 UTC crosses its midnight (21:00 UTC).
|
||||
func TestByDayInTheContainersZone(t *testing.T) {
|
||||
inZone(t, "Europe/Moscow")
|
||||
from := time.Date(2026, 10, 12, 20, 50, 0, 0, time.UTC)
|
||||
got := byDay(from, from.Add(20*time.Minute))
|
||||
if len(got) != 2 || got[0].down != 10*time.Minute || got[1].down != 10*time.Minute ||
|
||||
got[1].day.Format("2006-01-02 15:04") != "2026-10-13 00:00" {
|
||||
t.Errorf("parts = %+v, want ten minutes on the 12th and ten from Moscow's midnight", got)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// rewriteRetry is how often the signer tries again to write the selector
|
||||
// table when the write at its start failed.
|
||||
const rewriteRetry = 10 * time.Second
|
||||
|
||||
// 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 rewritten
|
||||
// from the registry (WriteSelectors): Postfix waits for these sockets
|
||||
// (build/postfix-wrapper.sh), so no message meets a table from before. A
|
||||
// rewrite that fails is tried again every rewriteRetry until it succeeds;
|
||||
// meanwhile what the signer finds no key for it defers, and the panel's
|
||||
// pages - Reload configuration among them - stay up.
|
||||
func serveSigner(ctx context.Context, cfg config, st *store.Store) error {
|
||||
domains := newMailStack(cfg, st).Domains
|
||||
if err := domains.WriteSelectors(); err != nil {
|
||||
log.Printf("dkim-signer: the selector table: %v - trying again every %s", err, rewriteRetry)
|
||||
go func() {
|
||||
tick := time.NewTicker(rewriteRetry)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := domains.WriteSelectors(); err != nil {
|
||||
log.Printf("dkim-signer: the selector table: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("dkim-signer: the selector table is written")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain runs the tests in UTC, whatever the zone of the machine they run
|
||||
// on: the panel shows times and counts days in time.Local, the container's
|
||||
// zone (TZ), and the fixtures are written in UTC. The tests of another zone
|
||||
// set it themselves (inZone).
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// inZone runs the rest of the test in the zone name, as a container started
|
||||
// with TZ=name; the test must not be parallel.
|
||||
func inZone(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Skipf("no zone %s here: %v", name, err)
|
||||
}
|
||||
time.Local = loc
|
||||
t.Cleanup(func() { time.Local = time.UTC })
|
||||
}
|
||||
@@ -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
|
||||
@@ -18,24 +18,20 @@
|
||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
//
|
||||
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
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 +71,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)
|
||||
@@ -102,10 +91,6 @@ func run(outPath, password string) error {
|
||||
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)
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -114,9 +99,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 +130,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 +149,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
|
||||
@@ -60,12 +32,6 @@ PANEL_SESSION_IDLE_DAYS=7
|
||||
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
||||
# SELFPOST_DNS_RESOLVERS=
|
||||
|
||||
# Time zone of the container, an IANA name: every time the panel shows, the
|
||||
# days it counts (today on Health, the days of the statistics, the log
|
||||
# filters) and the stamps Postfix writes in mail.log. Leave unset for UTC;
|
||||
# changing it is a restart. Server > Settings shows the zone in use.
|
||||
# TZ=Europe/Moscow
|
||||
|
||||
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||
# you know the exact address of your reverse proxy — trusting the wrong
|
||||
|
||||
@@ -1,56 +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, 4.2.2-0 - "4.2", "4" and "latest"
|
||||
# are rebuilt every night - and it is the build the comments in
|
||||
# antispam/rspamd/ were read against (rspamd 4.2.2, rspamd-docker v4.2.2+0;
|
||||
# 3.11.1-3 until 2.14.4, security audit 2026-10-17, S4); 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 4.2.2, conf/worker-proxy.inc, "milter = yes" for the
|
||||
# rspamd_proxy worker, bound to *:11332 by antispam/rspamd/worker-proxy.inc
|
||||
# while every other worker stays on loopback (RSPAMD_LOCAL_ADDR below);
|
||||
# it runs as uid 11333 (rspamd-docker v4.2.2+0, Dockerfile, USER
|
||||
# 11333:11333) and needs no capability: it starts unprivileged, binds
|
||||
# ports above 1024 and runs nothing setuid (src/rspamd.c, detect_priv()).
|
||||
image: rspamd/rspamd:4.2.2-0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Workers on loopback; the proxy alone is put back on every address.
|
||||
RSPAMD_LOCAL_ADDR: localhost
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||
pids_limit: 256
|
||||
# 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
|
||||
# The proxy (the milter) on the compose network, nothing else.
|
||||
- ./antispam/rspamd/worker-proxy.inc:/etc/rspamd/local.d/worker-proxy.inc:ro
|
||||
- ./data/antispam:/etc/rspamd/selfpost:ro
|
||||
@@ -1,77 +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 4.2.2 (as 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. The
|
||||
# same again on 2026-10-17 in rspamd/rspamd:4.2.2-0 (from 10.0.0.5), and the
|
||||
# signature equal to HMAC-SHA256 of the queue id computed with openssl.
|
||||
#
|
||||
# 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 4.2.2 (as 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 4.2.2 (as 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
|
||||
# 4.2.2 (as 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 4.2.2 (as 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,18 +0,0 @@
|
||||
# Mounted into the sidecar as /etc/rspamd/local.d/worker-proxy.inc.
|
||||
#
|
||||
# The milter - the proxy worker - is the one listener SelfPost needs on the
|
||||
# compose network. The fragment sets RSPAMD_LOCAL_ADDR=localhost, which keeps
|
||||
# every worker on the container's loopback: the normal worker (11333), whose
|
||||
# scan accepts a client-named file or shared-memory input, and the controller
|
||||
# (11334), whose default password is the well-known "q1" (rspamd 4.2.2,
|
||||
# conf/rspamd.conf: each worker's bind_socket is the env variable LOCAL_ADDR,
|
||||
# localhost by default, and its port; conf/worker-controller.inc: password =
|
||||
# "q1"; the rspamd-docker v4.2.2+0 Dockerfile sets RSPAMD_LOCAL_ADDR=* for all
|
||||
# three). This line puts the proxy alone back on every address: a local.d
|
||||
# scalar at priority 1 replaces the default one (contrib/libucl/ucl_parser.c,
|
||||
# UCL_DUPLICATE_MERGE). The proxy still reaches the normal worker on
|
||||
# localhost, as before (security audit 2026-10-17, S4).
|
||||
#
|
||||
# rspamd expands its template syntax in these files, comments included:
|
||||
# quote none of it here.
|
||||
bind_socket = "*:11332";
|
||||
@@ -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, mod_proxy/mod_proxy_http and mod_rewrite enabled (`a2enmod ssl proxy proxy_http rewrite`).
|
||||
# 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,29 +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, when the image was the
|
||||
# moving `caddy:2`; it is pinned below now, and a bump re-reads these.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -32,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
|
||||
@@ -41,18 +31,13 @@ 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
|
||||
|
||||
caddy:
|
||||
# Pinned to a release: "2" moves under a running server (security audit
|
||||
# 2026-10-17, I4). Bump it deliberately.
|
||||
image: caddy:2.11.7
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||
pids_limit: 256
|
||||
depends_on:
|
||||
- selfpost
|
||||
ports:
|
||||
|
||||
@@ -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,101 +15,74 @@
|
||||
# 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.16.0
|
||||
image: ghcr.io/mixeme/selfpost:1.2.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
|
||||
# resolver synthesises. Empty uses public defaults.
|
||||
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
||||
# The zone of every time the panel shows and of mail.log's stamps, e.g.
|
||||
# Europe/Moscow. Empty is UTC.
|
||||
TZ: "${TZ:-}"
|
||||
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
||||
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
||||
# 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 runs as root 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; supervisord, Postfix's master, cert-reload and
|
||||
# logrotate stay root for the container's life, which is inherent to
|
||||
# them (security audit 2026-10-17, I3) — so this cannot be
|
||||
# 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);
|
||||
@@ -118,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:
|
||||
@@ -153,9 +113,3 @@ services:
|
||||
- SETGID
|
||||
- DAC_OVERRIDE
|
||||
- KILL
|
||||
# A ceiling on processes and threads, so that a runaway or compromised
|
||||
# program cannot use up the host's (security audit 2026-10-17, I3).
|
||||
# Postfix starts up to 100 processes per service by default
|
||||
# (default_process_limit), and a busy server runs three smtpd services,
|
||||
# cleanup and smtp at once; 1024 leaves them room.
|
||||
pids_limit: 1024
|
||||
|
||||
@@ -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,25 +23,18 @@ 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
|
||||
restart: unless-stopped
|
||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||
pids_limit: 256
|
||||
depends_on:
|
||||
- selfpost
|
||||
ports:
|
||||
@@ -60,20 +46,10 @@ services:
|
||||
- ./nginx/certbot-www:/var/www/certbot:ro
|
||||
|
||||
certbot:
|
||||
# Pinned like every image here: "latest" moves under a running server
|
||||
# (security audit 2026-10-17, I4). Bump it deliberately.
|
||||
image: certbot/certbot:v5.8.0
|
||||
pids_limit: 64
|
||||
image: certbot/certbot:latest
|
||||
volumes:
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt
|
||||
- ./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
|
||||
@@ -40,8 +33,6 @@ services:
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
restart: unless-stopped
|
||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||
pids_limit: 256
|
||||
depends_on:
|
||||
- selfpost
|
||||
command:
|
||||
|
||||
@@ -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 keeps certificates in acme.json, so a PEM extraction
|
||||
# step is required"). Run this on the host, after Traefik has issued or
|
||||
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||
# acme.json is not itself watched by SelfPost/Postfix.
|
||||
#
|
||||
# Requires 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 10997
|
||||
# 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/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,120 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><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"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group" open><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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 <span class="tag is-warning is-light">not confirmed</span></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. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="pcur">Current password</label><div class="control"><input class="input" id="pcur" name="current_password" type="password" autocomplete="current-password"></div>
|
||||
<p class="help">Needed only to change the e-mail.</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>
|
||||
<form class="sp-form mt-4" method="post" action="#">
|
||||
<p class="help mb-3">A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.</p>
|
||||
<button type="submit" class="button">Send the link again</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 · Copyright © 2026 Mikhail Yenuchenko · <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,117 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><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"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group" open><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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="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>
|
||||
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password" required></div>
|
||||
<p class="help">The one you sign in with, asked at every download: the archive holds every secret of the server, and a session left open must not be enough to take it.</p></div>
|
||||
<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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,146 +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"),
|
||||
("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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
|
||||
' · <a href="#">Source</a> · No warranty</footer>')
|
||||
|
||||
|
||||
def touchnav(section, page):
|
||||
"""The navigation below 1024 px: a Menu that opens over the page, its
|
||||
sections folded but the current one - details elements, no script."""
|
||||
out = ['<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">']
|
||||
for key, label, href, _icon, pages in NAV:
|
||||
if not pages:
|
||||
cur = ' aria-current="page"' if key == section else ""
|
||||
out.append(f'<a href="{href}"{cur}>{label}</a>')
|
||||
continue
|
||||
opened = " open" if key == section else ""
|
||||
out.append(f'<details class="sp-touchnav-group"{opened}><summary>{label}</summary>')
|
||||
for pkey, plabel, phref, picon in pages:
|
||||
cur = ' aria-current="page"' if key == section and pkey == page else ""
|
||||
out.append(f'<a href="{phref}"{cur}><i class="ti {picon}"></i>{plabel}</a>')
|
||||
out.append('</details>')
|
||||
opened = " open" if section == "user" else ""
|
||||
out += [f'<details class="sp-touchnav-group"{opened}><summary><i class="ti ti-user-circle"></i>admin</summary>',
|
||||
'<a href="account.html"><i class="ti ti-key"></i>Account</a>',
|
||||
'<a href="help.html"><i class="ti ti-help"></i>Help</a>',
|
||||
'<form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form>',
|
||||
'</details>', '</div></details>']
|
||||
return "".join(out)
|
||||
|
||||
|
||||
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>' + touchnav(section, page) + '</div>',
|
||||
' <div class="navbar-menu">', ' <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,298 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group"><summary>Outbound</summary><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"><i class="ti ti-report-analytics"></i>DMARC reports</a></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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 · folded when DNS holds it as shown</div></div>
|
||||
<details class="sp-record">
|
||||
<summary class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small><span class="sp-record-toggle"><i class="ti ti-eye"></i><span>Show values</span><span>Hide values</span></span></summary>
|
||||
<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>
|
||||
</details>
|
||||
<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</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-ll"><span class="sp-d">13:58:07</span> an ordinary line
|
||||
</span><span class="sp-ll"><span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
|
||||
</span><span class="sp-ll"><span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span>
|
||||
</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>Label with a tag <span class="tag is-success is-light">matches</span></dt><dd>a verdict on the value</dd></div>
|
||||
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
|
||||
<div class="sp-wide"><dt>sp-wide</dt><dd class="sp-mono">a value that takes the whole row, a line of a server's own</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</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00</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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,32 +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>Confirm e-mail · 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>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<div class="sp-form">
|
||||
<p class="sp-kicker">mail.example.org</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>Sign in to the account in this browser, then open the link again.</div>
|
||||
<p><a href="login.html">Sign in</a></p>
|
||||
</div>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,32 +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>Confirm e-mail · 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>
|
||||
<div class="columns is-gapless sp-signin">
|
||||
<div class="column is-half sp-signin-brand">
|
||||
<div>
|
||||
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||
<p>YOUR SMTP. YOUR RULES.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half sp-signin-form">
|
||||
<form class="sp-form" method="post" action="account.html">
|
||||
<p class="sp-kicker">mail.example.org · admin</p>
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<p class="sp-muted mb-4"><span class="sp-mono">mix@example.org</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
|
||||
</form>
|
||||
<p class="sp-foot">Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,109 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><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></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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</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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,118 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><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></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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</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 03:00 – 21 Sep 03:00</dd><dd class="sp-muted">The reporter's day, 20 Sep: reports count their days in UTC, and the title and the lists name that day. The times are this panel's zone.</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40</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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,104 +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><details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel"><a href="overview.html">Overview</a><details class="sp-touchnav-group" open><summary>Outbound</summary><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></details><details class="sp-touchnav-group"><summary>Inbound</summary><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"><i class="ti ti-filter"></i>Filter lists</a></details><details class="sp-touchnav-group"><summary>Server</summary><a href="health.html"><i class="ti ti-heartbeat"></i>Health</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></details><details class="sp-touchnav-group"><summary><i class="ti ti-user-circle"></i>admin</summary><a href="account.html"><i class="ti ti-key"></i>Account</a><a href="help.html"><i class="ti ti-help"></i>Help</a><form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details></div></details></div>
|
||||
<div class="navbar-menu">
|
||||
<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="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</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</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 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||