Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83d326217 | ||
|
|
4921416384 | ||
|
|
2f7b5d95c3 | ||
|
|
5e0adb2de5 | ||
|
|
1bcfca1586 | ||
|
|
d1f22fbcc8 | ||
|
|
2b69605cf9 | ||
|
|
af9867bc2f | ||
|
|
10f5b4e8e1 | ||
|
|
50f1e4b298 | ||
|
|
b29f4845ba | ||
|
|
1e4746e52f | ||
|
|
ab2cd885c4 | ||
|
|
4204ee279c | ||
|
|
81190a7aad | ||
|
|
5a41d34cac | ||
|
|
0aa7318a41 | ||
|
|
1b13487356 | ||
|
|
fb26f7656a | ||
|
|
d88a8a6cba | ||
|
|
aa9a95ce4a | ||
|
|
23d52bab7c | ||
|
|
b81a261d01 | ||
|
|
72126b63af | ||
|
|
4a51db277b | ||
|
|
6adbb41459 | ||
|
|
1e4623b6e1 | ||
|
|
bb26e5022e | ||
|
|
b4a9b93cf2 | ||
|
|
a8ded7ecc8 | ||
|
|
33ee08f7b6 | ||
|
|
27aeadc71d | ||
|
|
c1ec4fbd79 | ||
|
|
8538b8d5f6 | ||
|
|
909145e5c8 | ||
|
|
d0bf4a4226 | ||
|
|
0d98d92642 | ||
|
|
6218540211 | ||
|
|
705239568b | ||
|
|
46191172d6 | ||
|
|
b99f60c1d6 | ||
|
|
41c3e6e896 | ||
|
|
c6a75ce775 | ||
|
|
f47cf39802 | ||
|
|
9edb39a8ee | ||
|
|
f517e8da41 | ||
|
|
2f888dc7be | ||
|
|
5598b62e4e | ||
|
|
32f5085c48 | ||
|
|
5601f73622 | ||
|
|
e17b1680bd | ||
|
|
5b63da0e49 | ||
|
|
e9aaed1c7b | ||
|
|
1bd71c10d0 | ||
|
|
74acf09212 | ||
|
|
ba07decbb0 | ||
|
|
4fc5cff266 | ||
|
|
ac8c2af500 | ||
|
|
9e3f0d0fad | ||
|
|
403c76d17d | ||
|
|
d9ab7bfa45 | ||
|
|
18bfd148ec | ||
|
|
e70ba9046e | ||
|
|
02afa0fa80 | ||
|
|
97a0c7e508 | ||
|
|
efdc2d63fb | ||
|
|
8dfb483244 | ||
|
|
e6af1a7866 | ||
|
|
aab35f0679 | ||
|
|
0570608738 | ||
|
|
1cf8bfcbe2 | ||
|
|
4761991dd5 | ||
|
|
8355479e03 | ||
|
|
6216e430e9 | ||
|
|
a1e7e05d26 | ||
|
|
fae0068c4a | ||
|
|
b829625066 | ||
|
|
71048922b9 | ||
|
|
5caca024c3 | ||
|
|
8289ed3916 | ||
|
|
958d07fcdd | ||
|
|
f0cd804cb3 | ||
|
|
3950bd333b | ||
|
|
584f8cbc1c | ||
|
|
6671bd3393 | ||
|
|
00e36df553 | ||
|
|
b0ebe061b5 | ||
|
|
601e183e0c | ||
|
|
f9e259a66d | ||
|
|
44e79c4120 | ||
|
|
1d30605533 | ||
|
|
b87baa6dd6 | ||
|
|
9420e30d6f | ||
|
|
93cf1de3b7 | ||
|
|
6b8658602d | ||
|
|
3a520d9ec7 | ||
|
|
4015ecd70d | ||
|
|
ad014ecdc1 | ||
|
|
de6f7a88da | ||
|
|
5985977f21 | ||
|
|
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 |
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
||||||
|
globs: docs/assets/panel-ui/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Panel UI system
|
||||||
|
|
||||||
|
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
||||||
|
|
||||||
|
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
||||||
|
|
||||||
|
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
||||||
|
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
||||||
|
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
||||||
|
- Confirm/login/user form → `measure`.
|
||||||
|
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
||||||
|
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
||||||
|
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
||||||
|
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
||||||
|
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
||||||
|
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
.gitattributes
|
.gitattributes
|
||||||
dev
|
dev
|
||||||
docs
|
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
|
bin
|
||||||
*.exe
|
*.exe
|
||||||
README.md
|
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
|
|
||||||
@@ -6,14 +6,14 @@ name: release
|
|||||||
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
|
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
|
||||||
#
|
#
|
||||||
# Native per-architecture builds (see docs/development.md), not qemu: running
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# bytes that passed e2e. Per-arch tags are pushed only so imagetools can
|
||||||
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
|
# assemble the multi-arch manifest; merge removes them from GHCR so operators
|
||||||
# drop a tag without deleting the image behind it.
|
# see a single version tag.
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
@@ -66,14 +66,14 @@ jobs:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: v${{ needs.prepare.outputs.version }}
|
ref: v${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
- 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)
|
- 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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: build/Dockerfile
|
file: build/Dockerfile
|
||||||
@@ -83,49 +83,16 @@ jobs:
|
|||||||
VERSION=${{ needs.prepare.outputs.version }}
|
VERSION=${{ needs.prepare.outputs.version }}
|
||||||
tags: selfpost:e2e
|
tags: selfpost:e2e
|
||||||
|
|
||||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
cache-dependency-path: test/e2e/go.sum
|
cache-dependency-path: test/e2e/go.sum
|
||||||
|
|
||||||
- name: e2e (gates publishing — see docs/development.md)
|
- 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 ./...
|
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
|
- name: Log in to ghcr.io
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -166,14 +133,14 @@ jobs:
|
|||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: v${{ needs.prepare.outputs.version }}
|
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
|
- name: Log in to ghcr.io
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -210,35 +177,28 @@ jobs:
|
|||||||
backoff=$((backoff * 2))
|
backoff=$((backoff * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Remove orphaned untagged versions from GHCR
|
- name: Remove per-arch tags from GHCR
|
||||||
# Re-publishing a version moves its tags to new manifests and leaves the
|
# Side-effect tags for imagetools assembly only — not part of the public
|
||||||
# old ones behind as untagged "sha256:..." package versions. Delete only
|
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
||||||
# those that no tagged manifest references: the images behind a
|
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
||||||
# 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:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
image="ghcr.io/${{ github.repository }}"
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
owner="${{ github.repository_owner }}"
|
owner="${{ github.repository_owner }}"
|
||||||
pkg="${{ github.event.repository.name }}"
|
pkg="${{ github.event.repository.name }}"
|
||||||
api="/users/${owner}/packages/container/${pkg}/versions"
|
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||||
versions=$(gh api "${api}?per_page=100" --paginate \
|
for suffix in amd64 arm64; do
|
||||||
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
|
tag="${version}-${suffix}"
|
||||||
keep=$(mktemp)
|
mapfile -t ids < <(gh api "$api" --paginate \
|
||||||
while IFS=$'\t' read -r id digest ntags; do
|
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
||||||
[ "$ntags" -gt 0 ] || continue
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
echo "$digest" >> "$keep"
|
echo "no GHCR package version for tag ${tag}"
|
||||||
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
|
continue
|
||||||
fi
|
fi
|
||||||
echo "deleting orphaned GHCR package version ${id} (${digest})"
|
for id in "${ids[@]}"; do
|
||||||
gh api -X DELETE "${api}/${id}"
|
echo "deleting GHCR package version ${id} (tag ${tag})"
|
||||||
done <<<"$versions"
|
gh api -X DELETE "${api}/${id}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|||||||
@@ -5,33 +5,16 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# The job reads the repository and nothing else (security audit 2026-10-17,
|
|
||||||
# review of stages 2-6).
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
# design-first.sh reads each commit's parent and the template history.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Design first
|
- uses: actions/setup-go@v5
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: |
|
|
||||||
go.sum
|
|
||||||
test/e2e/go.sum
|
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: |
|
run: |
|
||||||
@@ -45,31 +28,5 @@ jobs:
|
|||||||
- name: Vet
|
- name: Vet
|
||||||
run: go 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
|
- name: Test
|
||||||
run: go 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 ./...
|
|
||||||
|
|||||||
@@ -21,22 +21,6 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
|||||||
Licence: Zero-Clause BSD (0BSD)
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
https://github.com/bigskysoftware/htmx
|
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
|
IBM Plex Sans / IBM Plex Mono
|
||||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
Licence: SIL Open Font License 1.1
|
Licence: SIL Open Font License 1.1
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
||||||
|
|
||||||
Postfix and a small Go panel, which also signs outgoing mail with DKIM, run
|
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
|
||||||
together under `supervisord`; you configure domains, DKIM keys, and SASL
|
configure domains, DKIM keys, and SASL applications once, then point your apps
|
||||||
applications once, then point your apps at the SMTP endpoint.
|
at the SMTP endpoint.
|
||||||
|
|
||||||
SelfPost sends mail straight to the internet from **your own IP**, with
|
SelfPost sends mail straight to the internet from **your own IP**, with
|
||||||
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
|
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
|
||||||
@@ -26,12 +26,12 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
- 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 inbound relay on port 25 (backup-MX / forwarder; off by default)
|
||||||
- Optional DMARC aggregate report ingest on port 25 (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
|
- In-panel Help drawer and `/help` page for Status and domain controls
|
||||||
- Per-application client IP allow-list for submitting mail (optional; refused at MAIL FROM, not an authentication boundary)
|
- Per-application client IP allow-list for SMTP AUTH (optional)
|
||||||
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||||
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application overrides
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
- Full-server backup and single-domain export/import (optional password encryption)
|
- Full-server backup and single-domain export/import (optional password encryption)
|
||||||
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [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 |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||||
@@ -62,23 +62,9 @@ missing PTR record for you. Details: [Operator guide](docs/guide.md).
|
|||||||
|
|
||||||
- Docker + Compose v2 on the host
|
- Docker + Compose v2 on the host
|
||||||
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
|
- 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
|
- Rough sizing: **1 vCPU**, **512 MB–1 GB RAM**, **8–10 GB disk** (send log and
|
||||||
guide's [Machine requirements](docs/guide.md#machine-requirements)):
|
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)
|
||||||
| 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.
|
|
||||||
|
|
||||||
### Network and IP
|
### Network and IP
|
||||||
|
|
||||||
@@ -120,30 +106,15 @@ One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
|||||||
files, no compose files. Good for clicking through the UI on your machine;
|
files, no compose files. Good for clicking through the UI on your machine;
|
||||||
outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
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
|
```sh
|
||||||
docker run --rm -d --name selfpost-try \
|
docker run --rm -d --name selfpost-try \
|
||||||
-p 127.0.0.1:8080:8080 \
|
-p 127.0.0.1:8080:8080 \
|
||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:2.16.0
|
ghcr.io/mixeme/selfpost:1.9.1
|
||||||
```
|
```
|
||||||
|
|
||||||
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):
|
**Get the setup URL** (pick one):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -3,22 +3,15 @@
|
|||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
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`.
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---|---|
|
|---|---|
|
||||||
| latest 2.x | yes |
|
| latest 1.x | yes |
|
||||||
| earlier 2.x | no — upgrade first |
|
| earlier 1.x | no — upgrade first |
|
||||||
| 1.x | no — upgrade to 2.x (guide § Upgrading) |
|
|
||||||
| 0.x | no (pre-release) |
|
| 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
|
## Reporting a vulnerability
|
||||||
|
|
||||||
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
**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
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
a policy decision of that provider, not a defect
|
a policy decision of that provider, not a defect
|
||||||
- Denial of service through sheer volume against a single-tenant relay
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
- Vulnerabilities in upstream Postfix, the Go libraries SelfPost builds on,
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
or the base image — report
|
|
||||||
those upstream; if SelfPost's configuration makes an upstream issue
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
exploitable when it otherwise would not be, that *is* in scope
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# SelfPost — single Debian-slim image running postfix + panel under
|
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||||
# supervisord. Build from the repository root:
|
# supervisord. Build from the repository root:
|
||||||
#
|
#
|
||||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||||
|
|
||||||
# ---- build stage -------------------------------------------------------------
|
# ---- build stage -------------------------------------------------------------
|
||||||
FROM golang:1.26-trixie AS build
|
FROM golang:1.26-bookworm AS build
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Version stamped into both binaries; MUST match the image tag.
|
# Version stamped into both binaries; MUST match the image tag.
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
# Module metadata first for layer caching. third_party/go-milter is a module
|
# Module metadata first for layer caching.
|
||||||
# go.mod replaces go-milter with (third_party/go-milter/PATCHES.md): the
|
|
||||||
# download needs it.
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
COPY third_party ./third_party
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY cmd ./cmd
|
COPY cmd ./cmd
|
||||||
@@ -33,136 +30,57 @@ RUN go vet ./... \
|
|||||||
-o /out/selfpost-backup ./cmd/selfpost-backup
|
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||||
|
|
||||||
# ---- runtime stage -----------------------------------------------------------
|
# ---- runtime stage -----------------------------------------------------------
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# Preseed Postfix so its install is non-interactive and yields a working
|
# Preseed Postfix so its install is non-interactive and yields a working
|
||||||
# main.cf. The real relay configuration is generated by the panel.
|
# 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 \
|
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
postfix \
|
postfix \
|
||||||
|
opendkim \
|
||||||
|
opendkim-tools \
|
||||||
sasl2-bin \
|
sasl2-bin \
|
||||||
libsasl2-modules \
|
libsasl2-modules \
|
||||||
db-util \
|
db-util \
|
||||||
supervisor \
|
supervisor \
|
||||||
logrotate \
|
logrotate \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
openssl \
|
|
||||||
curl \
|
curl \
|
||||||
systemd-standalone-sysusers \
|
|
||||||
tzdata \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
|
# Unprivileged user for the panel process.
|
||||||
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||||
# 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, with ids of its own outside the
|
# Shared group bridging the unprivileged services: the panel generates
|
||||||
# system range for the same reason as selfpost's below: its files under
|
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
|
||||||
# /data show on the host by number, and a number from that range is a
|
# sender map, while OpenDKIM and Postfix (different users) must read them.
|
||||||
# system group of the host's too (useradd gave the panel group 998 in the
|
# Membership in this group — plus setgid dirs under /data (set up in
|
||||||
# trixie image, systemd-network on the test server's host; bookworm's
|
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||||
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
|
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||||
# back to it by name on every start, so files of the old ids follow.
|
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||||
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
|
RUN groupadd --system selfpost \
|
||||||
|
|
||||||
# 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 \
|
|
||||||
&& usermod -aG selfpost panel \
|
&& usermod -aG selfpost panel \
|
||||||
|
&& usermod -aG selfpost opendkim \
|
||||||
&& usermod -aG selfpost postfix
|
&& 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
|
# 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
|
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
||||||
# needed to reach Postfix's showq socket - but the documented deployment runs
|
# needed to reach Postfix's showq socket — but the documented deployment runs
|
||||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||||
# grants the same access without relying on a setgid escalation the hardening
|
# grants the same access without relying on a setgid escalation the hardening
|
||||||
# deliberately forbids. The same membership is what lets the panel submit its
|
# deliberately forbids. postdrop membership is read-side only: it does not let
|
||||||
# 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
|
# the panel bypass any Postfix restriction that a local user does not already
|
||||||
# have through the world-executable sendmail.
|
# 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.)
|
|
||||||
RUN usermod -aG postdrop panel
|
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.
|
# 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
|
&& chown panel:panel /run/selfpost /data
|
||||||
|
|
||||||
COPY --from=build /out/panel /usr/local/bin/panel
|
COPY --from=build /out/panel /usr/local/bin/panel
|
||||||
@@ -179,9 +97,9 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
|
|||||||
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||||
# that logrotate would silently ignore — see docs/development.md § Building
|
# that logrotate would silently ignore — see docs/development.md § Building
|
||||||
# binaries and the image.
|
# binaries and the image.
|
||||||
|
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||||
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
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-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/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/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||||
@@ -194,7 +112,7 @@ COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
|||||||
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
||||||
EXPOSE 8080 465 587 25
|
EXPOSE 8080 465 587 25
|
||||||
|
|
||||||
# Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
|
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
||||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
# 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 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
|||||||
@@ -1,51 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||||
|
|
||||||
If a managed program of the mail path exhausts its restart retries and
|
If any managed program exhausts its restart retries and enters FATAL, this
|
||||||
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
|
listener signals supervisord (its parent) to terminate, so the whole container
|
||||||
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
|
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||||
terminate, so the whole container exits and Docker's `restart` policy starts it
|
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||||
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).
|
|
||||||
|
|
||||||
Communication with supervisord uses the event listener protocol over stdin and
|
Communication with supervisord uses the event listener protocol over stdin and
|
||||||
stdout, so stdout must carry only protocol tokens.
|
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 os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
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):
|
def write_stdout(s):
|
||||||
@@ -53,20 +19,7 @@ def write_stdout(s):
|
|||||||
sys.stdout.flush()
|
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():
|
def main():
|
||||||
crashes = {} # program -> times of its unexpected exits in the window
|
|
||||||
while True:
|
while True:
|
||||||
# Tell supervisord we are ready for the next event.
|
# Tell supervisord we are ready for the next event.
|
||||||
write_stdout("READY\n")
|
write_stdout("READY\n")
|
||||||
@@ -76,37 +29,16 @@ def main():
|
|||||||
return
|
return
|
||||||
headers = dict(pair.split(":", 1) for pair in line.split())
|
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||||
payload_len = int(headers.get("len", 0))
|
payload_len = int(headers.get("len", 0))
|
||||||
payload = sys.stdin.read(payload_len) if payload_len else ""
|
if payload_len:
|
||||||
tokens = dict(
|
sys.stdin.read(payload_len)
|
||||||
pair.split(":", 1) for pair in payload.split() if ":" in pair
|
|
||||||
)
|
|
||||||
name = tokens.get("processname", "")
|
|
||||||
event = headers.get("eventname", "PROCESS_STATE_FATAL")
|
|
||||||
|
|
||||||
if event == "PROCESS_STATE_EXITED":
|
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||||
if name not in SPARED and tokens.get("expected") == "0":
|
# program can no longer be restarted. Take the container down.
|
||||||
now = time.monotonic()
|
sys.stderr.write(
|
||||||
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
|
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||||
times.append(now)
|
)
|
||||||
crashes[name] = times
|
sys.stderr.flush()
|
||||||
if len(times) >= CRASH_LIMIT:
|
os.kill(os.getppid(), signal.SIGTERM)
|
||||||
say(
|
|
||||||
"%s exited unexpectedly %d times in %d seconds; shutting down container"
|
|
||||||
% (name, len(times), CRASH_WINDOW)
|
|
||||||
)
|
|
||||||
shut_down()
|
|
||||||
crashes[name] = []
|
|
||||||
elif name in SPARED:
|
|
||||||
say(
|
|
||||||
"%s entered FATAL; it is not on the mail path, the container stays up"
|
|
||||||
% name
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
say(
|
|
||||||
"%s entered FATAL; shutting down container"
|
|
||||||
% (name or "a managed process")
|
|
||||||
)
|
|
||||||
shut_down()
|
|
||||||
|
|
||||||
write_stdout("RESULT 2\nOK")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,80 +58,36 @@ esac
|
|||||||
# — the one place still running as root — before handing off to supervisord,
|
# — the one place still running as root — before handing off to supervisord,
|
||||||
# which starts the panel as the panel user.
|
# which starts the panel as the panel user.
|
||||||
#
|
#
|
||||||
# Mode must stay world-traversable (0755): Postfix reaches its trees under
|
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||||
# /data as another user. Go's testing.TempDir is 0700, and a bare chown would
|
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||||
# leave that mode in place — OpenDKIM, which read its tables there until
|
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||||
# 2.8.0, then could not, and the container crash-looped (e2e
|
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||||
# TestHostnameGate/valid_hostname_starts).
|
|
||||||
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
|
|
||||||
# mode 0o700".)
|
|
||||||
chown panel:panel /data
|
chown panel:panel /data
|
||||||
chmod 755 /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
|
# Restored backups or previously-created state may contain panel-owned files
|
||||||
# under /data; make sure they stay writable without disturbing anything that a
|
# 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
|
# 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
|
# 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.
|
# normalised on its own below.
|
||||||
# A file's owner is a number: an entry whose user or group is not the panel's
|
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||||
# 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 {} +
|
|
||||||
|
|
||||||
# DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
|
# DKIM key tree. The panel (user `panel`) generates keys and writes
|
||||||
# the keys, writes the table and signs (internal/domain, Keys; internal/milter,
|
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||||
# sign.go). The selector table is written by the panel before its signer
|
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||||
# serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
|
# just restored from a backup:
|
||||||
# /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
|
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||||
# first start and kept the old tree for a rollback to 2.7.x until the next
|
# inherits the shared group OpenDKIM traverses;
|
||||||
# release, this one (CHANGELOG [2.9.0]). It is removed
|
# - private keys and tables group-readable (0640);
|
||||||
# once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
|
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||||
# old tree only - stops the start instead: the panel would make the server's
|
# daemon comes up cleanly with no domains yet.
|
||||||
# own name a new key, and its published record would no longer match; 2.8.x
|
mkdir -p /data/opendkim/keys
|
||||||
# copies them (guide § Upgrading). rm -rf removes a link, not what it points
|
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
|
||||||
# to, and follows none inside (GNU coreutils rm(1)); a link at the path is
|
[ -e "$t" ] || : > "$t"
|
||||||
# gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.)
|
done
|
||||||
has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; }
|
chown -R panel:selfpost /data/opendkim
|
||||||
if [ -d /data/opendkim ]; then
|
find /data/opendkim -type d -exec chmod 2750 {} +
|
||||||
if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
|
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||||
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
|
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||||
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 {} +
|
|
||||||
|
|
||||||
# Application SASL accounts. The panel (user `panel`) writes the
|
# Application SASL accounts. The panel (user `panel`) writes the
|
||||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||||
@@ -142,118 +98,52 @@ mkdir -p /data/sasl
|
|||||||
chown -R panel:selfpost /data/sasl
|
chown -R panel:selfpost /data/sasl
|
||||||
chmod 2750 /data/sasl
|
chmod 2750 /data/sasl
|
||||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
[ -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
|
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||||
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||||
mkdir -p /data/postfix/queue
|
mkdir -p /data/postfix/queue
|
||||||
[ -e /data/postfix/sender_login_maps ] || [ -L /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||||
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
|
# 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.
|
# 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
|
for f in relay_domains transport relay_recipients tls_policy; do
|
||||||
[ -e "/data/postfix/$f" ] || [ -L "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
||||||
|
chown panel:selfpost "/data/postfix/$f"
|
||||||
|
chmod 0640 "/data/postfix/$f"
|
||||||
done
|
done
|
||||||
# Every map here is the panel's to write and Postfix's to read - the sender
|
|
||||||
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
|
|
||||||
# handed to the panel, group selfpost, whatever its name. A restored archive's
|
|
||||||
# files arrive owned by whoever unpacked them (a backup records no owner, and
|
|
||||||
# tar run as root makes them root's), and the first start after a restore
|
|
||||||
# reads every one of them to compare it with the archive's record: one left
|
|
||||||
# to root alone is a restore refused on every start (review of audit stage 3,
|
|
||||||
# H1 - the DMARC maps were not among the names listed here).
|
|
||||||
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
|
|
||||||
chown panel:selfpost /data/postfix
|
chown panel:selfpost /data/postfix
|
||||||
chmod 2750 /data/postfix
|
chmod 2750 /data/postfix
|
||||||
|
chown panel:selfpost /data/postfix/sender_login_maps
|
||||||
# Filter lists for the external spam filter (written by the panel when
|
chmod 0640 /data/postfix/sender_login_maps
|
||||||
# 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
|
|
||||||
|
|
||||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
# 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
|
# `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
|
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||||
# resolve a "queued" send-log row survive a container recreate.
|
# resolve a "queued" send-log row survive a container recreate.
|
||||||
#
|
#
|
||||||
# postlogd creates a missing log itself, by default at 0600, which the
|
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||||
# unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
|
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||||
# maillog_file_permissions); so create it here (and re-normalise an existing
|
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||||
# 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
|
# directory keeps the shared group on anything created inside it later, and
|
||||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||||
# rotate a log whose directory is writable by a non-root group.
|
# 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
|
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
|
chown -R postfix:selfpost /data/log
|
||||||
chmod 2750 /data/log
|
chmod 2750 /data/log
|
||||||
find /data/log -type f -exec chmod 0640 {} +
|
find /data/log -type f -exec chmod 0640 {} +
|
||||||
|
|
||||||
# The milter socket directory. Postfix (user `postfix`) must actually CONNECT
|
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
||||||
# to the panel's milter sockets, not just probe them at start-up. The panel
|
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
|
||||||
# user creates them, so they are bridged to `postfix` through the shared
|
# probe them at start-up. The sockets are created by the opendkim and panel
|
||||||
# `selfpost` group: a group-owned + setgid dir means each socket created
|
# users respectively, so bridge them to `postfix` through the shared `selfpost`
|
||||||
# inside inherits group `selfpost`, and group-traversable (2750) lets postfix
|
# group: group-owned + setgid dirs mean each socket created inside inherits
|
||||||
# reach it. Without this, smtpd cannot talk to the signer and, because
|
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
|
||||||
# signing is strict (default_action=tempfail), defers all mail.
|
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
|
||||||
mkdir -p /run/selfpost
|
# (default_action=tempfail), rejects all mail.
|
||||||
|
mkdir -p /run/opendkim /run/selfpost
|
||||||
|
chown opendkim:selfpost /run/opendkim
|
||||||
chown panel:selfpost /run/selfpost
|
chown panel:selfpost /run/selfpost
|
||||||
chmod 2750 /run/selfpost
|
chmod 2750 /run/opendkim /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
|
|
||||||
|
|
||||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
# 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
|
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||||
@@ -261,27 +151,11 @@ own_queue
|
|||||||
# start the same way the /data normalisation above is.
|
# start the same way the /data normalisation above is.
|
||||||
/usr/local/bin/postfix-config.sh
|
/usr/local/bin/postfix-config.sh
|
||||||
|
|
||||||
# The queue tree, on a first start or after a restore, is built here:
|
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||||
# postfix-config.sh ends with "postfix check", whose check-fatal runs
|
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||||
# "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
|
if [ ! -d /data/postfix/queue/active ]; then
|
||||||
# conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
|
postfix set-permissions
|
||||||
# A "postfix set-permissions" for a missing active/ stood here and never ran
|
fi
|
||||||
# (assumptions C-P34).
|
chown -R postfix:postfix /data/postfix/queue
|
||||||
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
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
|
|||||||
@@ -7,49 +7,18 @@
|
|||||||
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||||
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||||
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
# 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:
|
# live container — so logrotate must be the one to create it.
|
||||||
# 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()).
|
|
||||||
#
|
#
|
||||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
# 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
|
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||||
# daily — polling merely bounds how late a legitimate rotation runs. The state
|
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||||
# 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.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
CONFIG=/etc/logrotate.d/mail
|
CONFIG=/etc/logrotate.d/mail
|
||||||
STATE=/data/log/.logrotate.status
|
|
||||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||||
|
|
||||||
# logrotate refuses configs writable by group or others and goes on without
|
# logrotate refuses configs writable by group or others and exits 0 while
|
||||||
# them — fail here so supervisord reports the fault. (logrotate 3.22.0,
|
# ignoring them — fail here so supervisord reports the fault.
|
||||||
# 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() {
|
logrotate_config_ok() {
|
||||||
mode=$(stat -c '%a' "$CONFIG")
|
mode=$(stat -c '%a' "$CONFIG")
|
||||||
mode=${mode#0}
|
mode=${mode#0}
|
||||||
@@ -70,19 +39,16 @@ if ! logrotate_config_ok; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_logrotate() {
|
run_logrotate() {
|
||||||
status=0
|
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||||
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
|
echo "$out" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
case "$out" in
|
case "$out" in
|
||||||
*Ignoring*|*Potentially\ dangerous\ mode*)
|
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||||
echo "$out" >&2
|
echo "$out" >&2
|
||||||
echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
|
logrotate_config_fatal
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
if [ "$status" -ne 0 ]; then
|
|
||||||
echo "$out" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
# 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.
|
# 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 {
|
/data/log/mail.log {
|
||||||
daily
|
daily
|
||||||
rotate 14
|
rotate 14
|
||||||
@@ -15,15 +12,7 @@
|
|||||||
compress
|
compress
|
||||||
delaycompress
|
delaycompress
|
||||||
create 0640 postfix selfpost
|
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
|
postrotate
|
||||||
/usr/sbin/postfix status >/dev/null 2>&1 || exit 0
|
|
||||||
/usr/sbin/postfix reload
|
/usr/sbin/postfix reload
|
||||||
endscript
|
endscript
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# OpenDKIM — per-domain signing.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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
|
#!/bin/sh
|
||||||
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
|
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||||
# audit 2026-10-12, H2).
|
# 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
|
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||||
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
|
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||||
# root with mode 0600, which a read-only bind mount owned by the host's user
|
# still bringing Postfix up then) and none until at least one interval has
|
||||||
# cannot give. A renewal of the mounted files therefore reaches Postfix only
|
# passed. A reload is harmless when nothing changed.
|
||||||
# through a new copy. Every interval this compares the mounted files with the
|
set -eu
|
||||||
# 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
|
|
||||||
|
|
||||||
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
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
|
while true; do
|
||||||
sleep "${INTERVAL}"
|
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
|
done
|
||||||
|
|||||||
@@ -13,39 +13,12 @@
|
|||||||
# a fixed literal or a container environment variable.
|
# a fixed literal or a container environment variable.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
|
# postconf -P refuses master.cf -o values that contain whitespace; inbound port
|
||||||
#
|
# 25 needs restriction lists and milter specs that include spaces. Patch the
|
||||||
# A master.cf -o value must not contain whitespace: master splits the line on
|
# smtp/inet service stanza directly instead.
|
||||||
# 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() {
|
master_cf_set_smtp_inet_o() {
|
||||||
param="$1"
|
param="$1"
|
||||||
value="$2"
|
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" '
|
awk -v p="$param" -v v="$value" '
|
||||||
/^smtp[ \t]+inet[ \t]/ && !done {
|
/^smtp[ \t]+inet[ \t]/ && !done {
|
||||||
print
|
print
|
||||||
@@ -76,27 +49,16 @@ master_cf_set_smtp_inet_o() {
|
|||||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||||
# fails. Fall back to the container hostname only outside a real deployment.
|
# 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)}"
|
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
|
||||||
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
# 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
|
# 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,
|
# but TLS handshakes on 465 fail until they appear.
|
||||||
# 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().
|
|
||||||
#
|
#
|
||||||
# Postfix's TLS_README advises a private key owned by root and readable by no
|
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||||
# other user; Postfix does not enforce it (smtpd loads the key as root in
|
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||||
# pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
|
# copy into a writable internal dir and normalise ownership before postconf
|
||||||
# give that mode - it is :ro and owned by the host's user, often readable by
|
# and `postfix check`.
|
||||||
# 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).
|
|
||||||
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||||
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||||
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||||
@@ -118,82 +80,13 @@ fi
|
|||||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
|
|
||||||
# The settings below reach main.cf and master.cf as numbers or time values.
|
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||||
# A value that is neither, or one under the parameter's minimum, passes
|
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||||
# `postfix check` and kills the daemon that reads it - every smtpd at its
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
# 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.
|
|
||||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.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
|
# 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,
|
# unprivileged panel can rewrite it and just ask for a reload.
|
||||||
# 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.
|
|
||||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||||
# Transit mail queue under /data so deferred/active messages survive container
|
# Transit mail queue under /data so deferred/active messages survive container
|
||||||
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||||
@@ -211,9 +104,6 @@ SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
|||||||
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
||||||
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
||||||
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
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_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||||
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
||||||
@@ -238,84 +128,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.
|
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
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 -----------------------------------------------------------------
|
# --- main.cf -----------------------------------------------------------------
|
||||||
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
# /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 \
|
postconf -e \
|
||||||
"myhostname=${HOSTNAME_VALUE}" \
|
"myhostname=${HOSTNAME_VALUE}" \
|
||||||
"maillog_file=${MAIL_LOG_PATH}" \
|
"maillog_file=${MAIL_LOG_PATH}" \
|
||||||
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||||
"maillog_file_permissions=0640" \
|
|
||||||
"mydestination=" \
|
"mydestination=" \
|
||||||
"relayhost=" \
|
"relayhost=" \
|
||||||
"inet_interfaces=all" \
|
"inet_interfaces=all" \
|
||||||
"inet_protocols=all"
|
"inet_protocols=all"
|
||||||
|
|
||||||
# postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
|
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||||
# html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
|
|
||||||
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
|
|
||||||
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||||
# image cannot lose the service.
|
# image cannot lose the service.
|
||||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
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
|
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||||
# these so a misfiled recipient never gets delivered locally. An empty
|
# these so a misfiled recipient never gets delivered locally.
|
||||||
# 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).
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"local_recipient_maps=" \
|
"local_recipient_maps=" \
|
||||||
"alias_maps=" \
|
"alias_maps=" \
|
||||||
"alias_database=" \
|
"alias_database="
|
||||||
"local_transport=error:local mail delivery is disabled"
|
|
||||||
|
|
||||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
# 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).
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtp_tls_security_level=may" \
|
"smtp_tls_security_level=may" \
|
||||||
"smtp_tls_note_starttls_offer=yes" \
|
"smtp_tls_note_starttls_offer=yes"
|
||||||
"smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt"
|
|
||||||
|
|
||||||
# TLS server material shared by every inbound service. auth_only guarantees
|
# 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,
|
# credentials are never accepted before TLS is up on any port.
|
||||||
# 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.
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||||
@@ -323,40 +167,11 @@ postconf -e \
|
|||||||
"smtpd_tls_auth_only=yes" \
|
"smtpd_tls_auth_only=yes" \
|
||||||
"smtpd_tls_loglevel=1"
|
"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
|
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
||||||
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
# 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;
|
# 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 sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||||
# the server realm, which equals the realm the accounts were created under.
|
# 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.)
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sasl_auth_enable=yes" \
|
"smtpd_sasl_auth_enable=yes" \
|
||||||
"smtpd_sasl_type=cyrus" \
|
"smtpd_sasl_type=cyrus" \
|
||||||
@@ -366,30 +181,17 @@ postconf -e \
|
|||||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||||
"broken_sasl_auth_clients=yes"
|
"broken_sasl_auth_clients=yes"
|
||||||
|
|
||||||
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
|
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
||||||
# up in this map with mail_addr_find(), the order being smtpd's and not
|
# full address first, then the "@domain" wildcard, so both address modes work
|
||||||
# texthash's: the full address, the address without its extension, then the
|
# from the same map.
|
||||||
# "@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.)
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||||
"recipient_delimiter=+" \
|
|
||||||
"queue_directory=${QUEUE_DIR}"
|
"queue_directory=${QUEUE_DIR}"
|
||||||
|
|
||||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
# and every authenticated sender address must be owned by its login. NO
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||||
# network. This is what makes an open relay impossible. (Postfix 3.10.13,
|
# network. This is what makes an open relay impossible.
|
||||||
# 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.)
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_helo_required=yes" \
|
"smtpd_helo_required=yes" \
|
||||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
@@ -397,99 +199,28 @@ postconf -e \
|
|||||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||||
|
|
||||||
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
# 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,
|
# journal-milter (level 2) is down.
|
||||||
# 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.
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
|
|
||||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||||
|
|
||||||
# Milter chain. The sender check comes first and is strict
|
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
|
||||||
# (default_action=tempfail): it holds the From: line against the login, the
|
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
||||||
# address a receiver holds the signature to, so a message it could not judge
|
# monitoring only and is fail-open (default_action=accept): its failure must
|
||||||
# must not be signed. While the panel does not answer on that socket, mail is
|
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
||||||
# 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.
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"milter_protocol=6" \
|
"milter_protocol=6" \
|
||||||
"milter_default_action=tempfail" \
|
"milter_default_action=tempfail" \
|
||||||
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${SIGN_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||||
"non_smtpd_milters={ unix:${SIGN_LOCAL_SOCK}, default_action=tempfail }"
|
"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)
|
# 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
|
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||||
# defaults (300s content) elapse. With default_action per milter, a
|
# defaults (300s content) elapse. With default_action per milter, a
|
||||||
# journal-milter hang then resolves to accept and a signer hang to tempfail,
|
# 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
|
# 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
|
# 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,
|
# operation.
|
||||||
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||||
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||||
@@ -499,26 +230,8 @@ postconf -e \
|
|||||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||||
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||||
# and the Cyrus config outside any chroot.
|
# 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 -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
"smtps/inet/smtpd_hide_client_session=yes" \
|
|
||||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
@@ -526,15 +239,10 @@ postconf -P \
|
|||||||
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||||
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||||
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
# 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,
|
# after disabling it does not leave the port listening.
|
||||||
# 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.)
|
|
||||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
"submission/inet/smtpd_hide_client_session=yes" \
|
|
||||||
"submission/inet/smtpd_tls_security_level=encrypt" \
|
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||||
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
@@ -543,23 +251,17 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
# --- 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's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||||
# 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
|
# 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
|
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
RELAY_DOMAINS_SETTING=""
|
RELAY_DOMAINS_SETTING=""
|
||||||
TRANSPORT_SETTING=""
|
TRANSPORT_SETTING=""
|
||||||
RECIPIENT_RESTRICTIONS="reject"
|
RECIPIENT_RESTRICTIONS="reject"
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
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
|
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||||
[ -e "$f" ] || [ -L "$f" ] || : > "$f"
|
[ -e "$f" ] || : > "$f"
|
||||||
done
|
done
|
||||||
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||||
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||||
@@ -567,46 +269,14 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
fi
|
fi
|
||||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||||
[ -e "$f" ] || [ -L "$f" ] || : > "$f"
|
[ -e "$f" ] || : > "$f"
|
||||||
done
|
done
|
||||||
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
|
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
|
||||||
# flags=, user= and argv= are arguments of pipe(8), not -o parameters:
|
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
|
||||||
# postconf -P refuses them ("unmatched service_name/type") and leaves a
|
postconf -P \
|
||||||
# bare "pipe", which dies with "fatal: missing user= command-line
|
"dmarc-ingest/unix-pipe/flags=Rq" \
|
||||||
# attribute" on every report. They go into the service line itself.
|
"dmarc-ingest/unix-pipe/user=panel" \
|
||||||
# (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
|
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest"
|
||||||
# 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
|
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||||
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
@@ -614,40 +284,15 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
fi
|
fi
|
||||||
# Comma, not a space, between check_recipient_access and its table: see
|
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||||
# 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
|
fi
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||||
"transport_maps=${TRANSPORT_SETTING}" \
|
"transport_maps=${TRANSPORT_SETTING}" \
|
||||||
"smtpd_reject_unlisted_recipient=yes"
|
"smtpd_reject_unlisted_recipient=yes"
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
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 \
|
postconf -e \
|
||||||
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
||||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||||
else
|
else
|
||||||
postconf -e \
|
postconf -e \
|
||||||
@@ -655,27 +300,16 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
"smtp_tls_policy_maps="
|
"smtp_tls_policy_maps="
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Milters on port 25. main.cf's chain (sender check and signer strict +
|
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is
|
||||||
# journal-milter) is for the SUBMISSION path only: the signer must not gate someone else's
|
# for the SUBMISSION path only: OpenDKIM must not gate someone else's
|
||||||
# inbound mail (a signing outage would defer it), and the journal-milter
|
# 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*
|
# would file inbound mail into the outbound send log under the *sender's*
|
||||||
# domain — polluting Deliveries and letting a forged From: consume that
|
# domain — polluting Deliveries and letting a forged From: consume that
|
||||||
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
# 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
|
# here: either the optional antispam milter, or nothing at all.
|
||||||
# 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=""
|
INBOUND_MILTERS=""
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
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
|
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._/-]* ) ;;
|
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
|
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||||
@@ -691,32 +325,6 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
esac
|
esac
|
||||||
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
||||||
fi
|
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_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||||
@@ -733,34 +341,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
fi
|
fi
|
||||||
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"
|
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 \
|
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_sasl_auth_enable=no" \
|
||||||
"smtp/inet/smtpd_tls_auth_only=no" \
|
"smtp/inet/smtpd_tls_auth_only=no" \
|
||||||
"smtp/inet/smtpd_sender_login_maps=" \
|
"smtp/inet/smtpd_sender_login_maps=" \
|
||||||
@@ -771,22 +353,9 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
"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_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
|
||||||
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
|
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
|
else
|
||||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||||
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
|
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true
|
||||||
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"relay_domains=" \
|
"relay_domains=" \
|
||||||
"transport_maps=" \
|
"transport_maps=" \
|
||||||
@@ -800,44 +369,13 @@ fi
|
|||||||
# Inside a container the chroot buys little (the container is the isolation
|
# 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
|
# 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
|
# 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
|
# and the rest.
|
||||||
# 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).
|
|
||||||
postconf -F "*/*/chroot=n"
|
postconf -F "*/*/chroot=n"
|
||||||
|
|
||||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
# 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. PLAIN/LOGIN
|
||||||
# only — both are safe because TLS is mandatory before auth on every port.
|
# 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
|
mkdir -p /etc/postfix/sasl
|
||||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||||
pwcheck_method: auxprop
|
pwcheck_method: auxprop
|
||||||
@@ -847,11 +385,7 @@ mech_list: PLAIN LOGIN
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Validate the generated configuration; fail loudly if postconf produced
|
# Validate the generated configuration; fail loudly if postconf produced
|
||||||
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
|
# anything Postfix rejects, before the wrapper tries to start it.
|
||||||
# 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.)
|
|
||||||
set +e
|
set +e
|
||||||
check_out=$(postfix check 2>&1)
|
check_out=$(postfix check 2>&1)
|
||||||
ec=$?
|
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
|
|
||||||
@@ -2,38 +2,22 @@
|
|||||||
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
||||||
# reach the milter sockets before they are listening.
|
# reach the milter sockets before they are listening.
|
||||||
#
|
#
|
||||||
# It blocks until the milter sockets — the panel's sender check, DKIM signer
|
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||||
# (two) and journal-milter and, with the inbound relay on, its
|
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||||
# inbound journal milter —
|
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||||
# are present, then runs `postfix start-fg` and stays its parent. If they are
|
# an explicit startup failure instead of a relay running blind.
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# This handles cold start only. Once Postfix runs, a milter that does not
|
# This handles cold start only. Runtime milter failures after a successful start
|
||||||
# answer costs what its default_action says (build/postfix-config.sh): mail is
|
# are governed by fail-open (milter_default_action).
|
||||||
# deferred for the signer and the sender check, and passes the journal-milter.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.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}"
|
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||||
INTERVAL=1
|
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
|
elapsed=0
|
||||||
for sock in $SOCKS; do
|
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||||
while [ ! -S "$sock" ]; do
|
while [ ! -S "$sock" ]; do
|
||||||
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||||
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
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"
|
echo "postfix-wrapper: milter socket ready: $sock"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "postfix-wrapper: milter sockets ready, starting postfix"
|
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||||
# Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
|
exec postfix start-fg
|
||||||
# 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"
|
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
; SelfPost process supervision.
|
; SelfPost process supervision.
|
||||||
;
|
;
|
||||||
; Start ORDER is enforced by priority=: the panel (which opens the sender
|
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||||
; check's, the DKIM signer's and the journal-milter's sockets), then the
|
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||||
; Postfix wrapper — which additionally blocks until the milter sockets are ready
|
; blocks until both milter sockets are ready before starting Postfix.
|
||||||
; 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.)
|
|
||||||
;
|
;
|
||||||
; If a managed process of the mail path exhausts its restart retries (FATAL),
|
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||||
; or exits unexpectedly five times in ten minutes, the crashexit event listener
|
; event listener brings the whole container down so Docker's restart policy can
|
||||||
; brings the whole container down so Docker's restart policy starts it again
|
; recreate it cleanly, rather than lingering with a dead component.
|
||||||
; (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).
|
|
||||||
|
|
||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
@@ -28,19 +16,15 @@ logfile_maxbytes=0
|
|||||||
pidfile=/run/supervisord.pid
|
pidfile=/run/supervisord.pid
|
||||||
loglevel=info
|
loglevel=info
|
||||||
|
|
||||||
; The control socket is reachable by the `supervisor` group, whose one member
|
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||||
; is the panel (the unprivileged `panel` user): it asks supervisord to run the
|
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||||
; Postfix reload, and for the processes' state. Whoever reaches the socket can
|
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
|
||||||
; stop, start or restart any program, so it is not the shared `selfpost`
|
; unreachable by world; only root and the two SelfPost services are in the
|
||||||
; group's: Postfix is in that one for the files under /data, and a fault in
|
; group. No HTTP auth is configured because access is gated by socket
|
||||||
; it must not reach every other program (audit 2026-10-12, stage 5). Not
|
; permissions, not the network.
|
||||||
; 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.)
|
|
||||||
[unix_http_server]
|
[unix_http_server]
|
||||||
file=/run/supervisor.sock
|
file=/run/supervisor.sock
|
||||||
chown=root:supervisor
|
chown=root:selfpost
|
||||||
chmod=0770
|
chmod=0770
|
||||||
|
|
||||||
[rpcinterface:supervisor]
|
[rpcinterface:supervisor]
|
||||||
@@ -49,10 +33,17 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
|||||||
[supervisorctl]
|
[supervisorctl]
|
||||||
serverurl=unix:///run/supervisor.sock
|
serverurl=unix:///run/supervisor.sock
|
||||||
|
|
||||||
; user=panel also gives the panel its supplementary groups, selfpost and
|
[program:opendkim]
|
||||||
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
|
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||||
; drop_privileges(), os.setgroups() from the user's memberships in
|
priority=100
|
||||||
; grp.getgrall().
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=3
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:panel]
|
[program:panel]
|
||||||
command=/usr/local/bin/panel
|
command=/usr/local/bin/panel
|
||||||
user=panel
|
user=panel
|
||||||
@@ -65,22 +56,12 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[program:postfix]
|
||||||
command=/usr/local/bin/postfix-wrapper.sh
|
command=/usr/local/bin/postfix-wrapper.sh
|
||||||
priority=300
|
priority=300
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startsecs=%(ENV_POSTFIX_STARTSECS)s
|
|
||||||
startretries=3
|
startretries=3
|
||||||
stopwaitsecs=15
|
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
@@ -89,20 +70,14 @@ stderr_logfile_maxbytes=0
|
|||||||
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
||||||
; reload` itself, and it cannot signal the Postfix master directly: `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
|
; start-fg` forks a separate master process, so a signal to the supervised
|
||||||
; foreground process never reaches it. Instead the panel asks
|
; 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,
|
; supervisord — over the group-accessible control socket — to run this program,
|
||||||
; which runs the canonical `postfix reload` as root - or exits 75 when no
|
; which executes the canonical `postfix reload` as root. autostart is off (it
|
||||||
; master runs (build/postfix-reload.sh). autostart is off (it only runs on
|
; only runs on demand) and a fast, clean exit is expected, so it never trips the
|
||||||
; demand). A program that cannot even be spawned twice in a row ends FATAL
|
; crashexit listener.
|
||||||
; (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.)
|
|
||||||
[program:postfix-reload]
|
[program:postfix-reload]
|
||||||
command=/usr/local/bin/postfix-reload.sh
|
command=/usr/sbin/postfix reload
|
||||||
autostart=false
|
autostart=false
|
||||||
autorestart=false
|
autorestart=false
|
||||||
startsecs=0
|
startsecs=0
|
||||||
@@ -113,9 +88,8 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; TLS-certificate refresh. Once a day it compares the certificate the
|
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
||||||
; reverse-proxy renews in the read-only mount with the copy Postfix reads, and
|
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||||
; when they differ copies it again and runs `postfix reload`.
|
|
||||||
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
; 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.
|
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||||
[program:cert-reload]
|
[program:cert-reload]
|
||||||
@@ -129,31 +103,23 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic logrotate for /data/log/mail.log (daily, 14 files kept). Runs as
|
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
|
||||||
; root so logrotate can read/rotate the log. Exits non-zero at once when the
|
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
|
||||||
; config is group/other-writable (logrotate would ignore it silently): with
|
; the config is group/other-writable (logrotate would ignore it silently);
|
||||||
; startsecs=1 that exit is too quick; once startretries are spent the program
|
; autorestart surfaces BACKOFF on the Status page.
|
||||||
; 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.
|
|
||||||
[program:logrotate]
|
[program:logrotate]
|
||||||
command=/usr/local/bin/logrotate-loop.sh
|
command=/usr/local/bin/logrotate-loop.sh
|
||||||
priority=400
|
priority=400
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startsecs=1
|
startsecs=0
|
||||||
startretries=3
|
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[eventlistener:crashexit]
|
||||||
command=/usr/local/bin/crashexit.py
|
command=/usr/local/bin/crashexit.py
|
||||||
events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
|
events=PROCESS_STATE_FATAL
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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,8 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||||
|
|
||||||
func runDMARCIngestMode() error {
|
func runDMARCIngestMode() error {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if !cfg.dmarcEnabled {
|
if !cfg.dmarcEnabled {
|
||||||
@@ -24,82 +26,19 @@ func runDMARCIngestMode() error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
recipient := ingestRecipient(os.Args[1:])
|
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT")))
|
||||||
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
|
if recipient == "" {
|
||||||
if !errors.Is(err, dmarc.ErrRejected) {
|
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
|
||||||
return err
|
}
|
||||||
}
|
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
|
||||||
// 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 {
|
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||||
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||||
}
|
}
|
||||||
logRejected(err)
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func isDMARCIngestInvocation() bool {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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,170 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The environment the image reads and the environment the documents describe
|
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||||
// are compared as they are, not as lists kept by hand beside them (audit
|
var documentedPublic = []string{
|
||||||
// 2026-10-12, stage 5 - the three lists this file held were compared with each
|
"SELFPOST_HOSTNAME",
|
||||||
// other and with nothing else). Read: what the panel's code asks of the
|
"SUBMISSION_ENABLE",
|
||||||
// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up
|
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
|
"RATE_LIMIT_WINDOW_SECONDS",
|
||||||
// operator's table in guide.md § Environment variables, with the paragraph
|
"SEND_LOG_RETENTION_DAYS",
|
||||||
// under it, and the internal list in architecture.md § Configuration.
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
var (
|
"TRUSTED_PROXY_CIDR",
|
||||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
"INBOUND_RELAY_ENABLE",
|
||||||
// A name kept in a constant, as the backup CLI keeps its password's.
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`)
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
)
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
func readFile(t *testing.T, path string) string {
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
t.Helper()
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// envRead is every variable the code and the scripts read.
|
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||||
func envRead(t *testing.T) map[string]bool {
|
var documentedInternal = []string{
|
||||||
t.Helper()
|
"SELFPOST_DATA_DIR",
|
||||||
keys := map[string]bool{}
|
"SELFPOST_DB_PATH",
|
||||||
for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} {
|
"SELFPOST_SETUP_TOKEN_FILE",
|
||||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
"PANEL_HTTP_ADDR",
|
||||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
"JOURNAL_MILTER_SOCKET",
|
||||||
return err
|
"MAIL_LOG",
|
||||||
}
|
"PANEL_COOKIE_SECURE",
|
||||||
text := readFile(t, path)
|
"OPENDKIM_SOCKET",
|
||||||
for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) {
|
"OPENDKIM_DIR",
|
||||||
keys[m[1]] = true
|
"DKIM_SELECTOR_DEFAULT",
|
||||||
}
|
"SASL_DB_PATH",
|
||||||
for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) {
|
"SASL_REALM",
|
||||||
keys[m[1]] = true
|
"POSTFIX_DIR",
|
||||||
}
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
return nil
|
"POSTFIX_QUEUE_DIR",
|
||||||
})
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
if err != nil {
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
t.Fatal(err)
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
}
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
}
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
if err != nil || len(scripts) == 0 {
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
t.Fatalf("no build scripts: %v", err)
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
}
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
for _, path := range scripts {
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
keys[m[1]] = true
|
"MILTER_WAIT_TIMEOUT",
|
||||||
}
|
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||||
}
|
"LOGROTATE_INTERVAL_SECONDS",
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// section is the text of doc from the first line that starts with start up
|
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||||
// to the next that starts with any of ends.
|
var documentedComposeFixed = []string{
|
||||||
func section(t *testing.T, doc, start string, ends ...string) string {
|
"TLS_CERT_FILE",
|
||||||
t.Helper()
|
"TLS_KEY_FILE",
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// envDocumented is every variable the documents name, and where.
|
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||||
func envDocumented(t *testing.T) map[string]string {
|
// Update together with loadConfig when adding a new key.
|
||||||
t.Helper()
|
var loadConfigKeys = []string{
|
||||||
keys := map[string]string{}
|
"SELFPOST_DATA_DIR",
|
||||||
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
|
"PANEL_HTTP_ADDR",
|
||||||
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
|
"JOURNAL_MILTER_SOCKET",
|
||||||
keys[m[1]] = "guide.md § Environment variables"
|
"MAIL_LOG",
|
||||||
}
|
"SEND_LOG_RETENTION_DAYS",
|
||||||
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
|
"SELFPOST_DB_PATH",
|
||||||
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
|
"SELFPOST_SETUP_TOKEN_FILE",
|
||||||
if _, ok := keys[m[1]]; !ok {
|
"SELFPOST_HOSTNAME",
|
||||||
keys[m[1]] = "architecture.md § Configuration"
|
"PANEL_COOKIE_SECURE",
|
||||||
}
|
"SUBMISSION_ENABLE",
|
||||||
}
|
"TRUSTED_PROXY_CIDR",
|
||||||
return keys
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
|
"TLS_CERT_FILE",
|
||||||
|
"OPENDKIM_SOCKET",
|
||||||
|
"OPENDKIM_DIR",
|
||||||
|
"DKIM_SELECTOR_DEFAULT",
|
||||||
|
"SASL_DB_PATH",
|
||||||
|
"SASL_REALM",
|
||||||
|
"POSTFIX_DIR",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvironmentReadIsDocumented(t *testing.T) {
|
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||||
doc := envDocumented(t)
|
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||||
var missing []string
|
var buildScriptKeys = []string{
|
||||||
for key := range envRead(t) {
|
"SELFPOST_HOSTNAME",
|
||||||
if _, ok := doc[key]; !ok {
|
"TLS_CERT_FILE",
|
||||||
missing = append(missing, key)
|
"TLS_KEY_FILE",
|
||||||
}
|
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
}
|
"RATE_LIMIT_WINDOW_SECONDS",
|
||||||
slices.Sort(missing)
|
"OPENDKIM_SOCKET",
|
||||||
for _, key := range missing {
|
"JOURNAL_MILTER_SOCKET",
|
||||||
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)
|
"MAIL_LOG",
|
||||||
}
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
|
"SASL_DB_PATH",
|
||||||
|
"SUBMISSION_ENABLE",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
"MILTER_WAIT_TIMEOUT",
|
||||||
|
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||||
|
"LOGROTATE_INTERVAL_SECONDS",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvironmentDocumentedIsRead(t *testing.T) {
|
func documentedKeys() []string {
|
||||||
read := envRead(t)
|
keys := append([]string{}, documentedPublic...)
|
||||||
var stale []string
|
keys = append(keys, documentedInternal...)
|
||||||
for key, where := range envDocumented(t) {
|
keys = append(keys, documentedComposeFixed...)
|
||||||
if !read[key] {
|
slices.Sort(keys)
|
||||||
stale = append(stale, key+" ("+where+")")
|
return slices.Compact(keys)
|
||||||
}
|
|
||||||
}
|
|
||||||
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 TestLoadConfigKeysDocumented(t *testing.T) {
|
||||||
func TestEnvironmentParsersFindKeys(t *testing.T) {
|
doc := documentedKeys()
|
||||||
read := envRead(t)
|
for _, key := range loadConfigKeys {
|
||||||
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
|
if !slices.Contains(doc, key) {
|
||||||
if !read[key] {
|
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||||
t.Errorf("%s not found among the keys read", key)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||||
|
doc := documentedKeys()
|
||||||
|
for _, key := range buildScriptKeys {
|
||||||
|
if !slices.Contains(doc, key) {
|
||||||
|
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||||
|
read := append([]string{}, loadConfigKeys...)
|
||||||
|
read = append(read, buildScriptKeys...)
|
||||||
|
slices.Sort(read)
|
||||||
|
read = slices.Compact(read)
|
||||||
|
|
||||||
|
for _, key := range documentedKeys() {
|
||||||
|
if !slices.Contains(read, key) {
|
||||||
|
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,34 +24,31 @@ type mailStack struct {
|
|||||||
Domains *domain.Service
|
Domains *domain.Service
|
||||||
Apps *app.Service
|
Apps *app.Service
|
||||||
Inbound *inbound.Service
|
Inbound *inbound.Service
|
||||||
Lists *inbound.Lists
|
|
||||||
DMARC *dmarc.Service
|
DMARC *dmarc.Service
|
||||||
pf *postfix.Postfix
|
pf *postfix.Postfix
|
||||||
|
odk *domain.OpenDKIM
|
||||||
inboundEnabled bool
|
inboundEnabled bool
|
||||||
dmarcEnabled bool
|
dmarcEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||||
pf := postfix.New(cfg.postfixDir)
|
pf := postfix.New(cfg.postfixDir)
|
||||||
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||||
domains := domain.NewService(st, domain.NewKeys(cfg.dkimDir), apps, cfg.dkimSelectorDef)
|
domains := domain.NewService(st, odk, 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)
|
inb := inbound.NewService(st, pf)
|
||||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||||
return &mailStack{
|
return &mailStack{
|
||||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||||
Lists: inbound.NewLists(st, cfg.antispamDir),
|
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||||
pf: pf, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resync rebuilds the selector table and Postfix's maps from SQLite and
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||||
// reloads Postfix — the same work as the Health page's Reload button.
|
// reloads both daemons — the same work as the Status page's Reload button.
|
||||||
func (m *mailStack) Resync() error {
|
func (m *mailStack) Resync() error {
|
||||||
if err := m.Domains.Resync(); err != nil {
|
if err := m.Domains.Resync(); err != nil {
|
||||||
return fmt.Errorf("dkim resync: %w", err)
|
return fmt.Errorf("opendkim resync: %w", err)
|
||||||
}
|
}
|
||||||
if err := m.Apps.Resync(); err != nil {
|
if err := m.Apps.Resync(); err != nil {
|
||||||
return fmt.Errorf("postfix resync: %w", err)
|
return fmt.Errorf("postfix resync: %w", err)
|
||||||
@@ -60,11 +57,6 @@ func (m *mailStack) Resync() error {
|
|||||||
if err := m.Inbound.Resync(); err != nil {
|
if err := m.Inbound.Resync(); err != nil {
|
||||||
return fmt.Errorf("inbound maps resync: %w", err)
|
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 m.dmarcEnabled {
|
||||||
if err := m.DMARC.Resync(); err != nil {
|
if err := m.DMARC.Resync(); err != nil {
|
||||||
@@ -76,6 +68,7 @@ func (m *mailStack) Resync() error {
|
|||||||
|
|
||||||
func (m *mailStack) skipReloadForTest() {
|
func (m *mailStack) skipReloadForTest() {
|
||||||
m.pf.SetReloadHook(func() error { return nil })
|
m.pf.SetReloadHook(func() error { return nil })
|
||||||
|
m.odk.SetReloadHook(func() error { return nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
||||||
@@ -96,11 +89,9 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
ms := newMailStack(cfg, st)
|
ms := newMailStack(cfg, st)
|
||||||
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||||
// effective config, including a manual override; the panel keeps this
|
// effective config, including a manual override; the panel keeps this
|
||||||
// snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
|
// snapshot for the process lifetime (architecture.md).
|
||||||
// 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()
|
retryPolicy := postfix.LoadRetryPolicy()
|
||||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
@@ -111,50 +102,19 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
SMTPBanner: cfg.smtpBanner,
|
|
||||||
TimeZone: cfg.timeZone,
|
|
||||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
|
||||||
SignSocket: cfg.signSocket,
|
|
||||||
SignLocalSocket: cfg.signLocalSocket,
|
|
||||||
JournalSocket: cfg.journalSocket,
|
JournalSocket: cfg.journalSocket,
|
||||||
InboundJournalSocket: cfg.inboundJournalSocket,
|
|
||||||
SenderSocket: cfg.senderSocket,
|
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
DNSResolvers: cfg.dnsResolvers,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
RetryPolicy: retryPolicy,
|
RetryPolicy: retryPolicy,
|
||||||
InboundEnabled: cfg.inboundEnabled,
|
InboundEnabled: cfg.inboundEnabled,
|
||||||
InboundAntispamMilter: cfg.inboundAntispamMilter,
|
|
||||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
|
||||||
DMARCEnabled: cfg.dmarcEnabled,
|
DMARCEnabled: cfg.dmarcEnabled,
|
||||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||||
|
|
||||||
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
|
||||||
}, cfg.setupTokenPath)
|
}, 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
|
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||||
// the database handle shared by all roles: setup, login and the authenticated
|
// the database handle shared by all roles: setup, login and the authenticated
|
||||||
// panel surface (security.md).
|
// panel surface (security.md).
|
||||||
@@ -167,12 +127,14 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
return err
|
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.
|
// Shut the server down cleanly when the process is asked to stop.
|
||||||
shutDown := make(chan struct{})
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(shutDown)
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -183,13 +145,5 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
return err
|
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
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/inbound"
|
|
||||||
"github.com/mixeme/selfpost/internal/milter"
|
"github.com/mixeme/selfpost/internal/milter"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
@@ -16,65 +15,31 @@ import (
|
|||||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||||
// (architecture.md § Mail path), recording accepted messages into the send
|
// (architecture.md § Mail path), recording accepted messages into the send
|
||||||
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
// 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 {
|
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||||
ln, err := listenMilter(cfg.journalSocket)
|
socketPath := 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) {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
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
|
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||||
// listen below fails with "address already in use" (unix(7): the socket
|
// listen below fails with "address already in use".
|
||||||
// file "must be deleted by the caller when it is no longer needed"; bind(2)
|
|
||||||
// EADDRINUSE).
|
|
||||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ln, err := net.Listen("unix", socketPath)
|
ln, err := net.Listen("unix", socketPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
// 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).
|
// 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 {
|
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||||
ln.Close()
|
ln.Close()
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
return ln, nil
|
|
||||||
|
log.Printf("journal-milter listening on %s", socketPath)
|
||||||
|
return milter.Serve(ctx, ln, st)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/backup"
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"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/logtail"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
@@ -36,12 +33,7 @@ func main() {
|
|||||||
log.SetPrefix("dmarc-ingest: ")
|
log.SetPrefix("dmarc-ingest: ")
|
||||||
if err := runDMARCIngestMode(); err != nil {
|
if err := runDMARCIngestMode(); err != nil {
|
||||||
log.Printf("ingest failed: %v", err)
|
log.Printf("ingest failed: %v", err)
|
||||||
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
|
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
@@ -67,13 +59,8 @@ func main() {
|
|||||||
type config struct {
|
type config struct {
|
||||||
httpAddr string
|
httpAddr string
|
||||||
journalSocket string
|
journalSocket string
|
||||||
// The sender check's socket; the default mirrors build/postfix-config.sh.
|
mailLog string
|
||||||
senderSocket string
|
retentionDays int
|
||||||
// The DKIM signer's sockets: applications' mail and the panel's own
|
|
||||||
// (cmd/panel/signer.go).
|
|
||||||
signSocket, signLocalSocket string
|
|
||||||
mailLog string
|
|
||||||
retentionDays int
|
|
||||||
|
|
||||||
dataDir string
|
dataDir string
|
||||||
dbPath string
|
dbPath string
|
||||||
@@ -88,45 +75,23 @@ type config struct {
|
|||||||
rateLimitMessagesPerIP int
|
rateLimitMessagesPerIP int
|
||||||
rateLimitWindowSeconds int
|
rateLimitWindowSeconds int
|
||||||
|
|
||||||
// Read-only input to the panel's status page: the certificate Postfix
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
// serves. The default mirrors build/postfix-config.sh, so the status
|
// serves and the two milter sockets it connects to. The defaults mirror
|
||||||
// page checks exactly what Postfix was configured with.
|
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||||
tlsCertFile string
|
// was configured with.
|
||||||
// tlsServedCertFile is the certificate Postfix serves (its copy of
|
tlsCertFile string
|
||||||
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
|
opendkimSocket string
|
||||||
// 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
|
|
||||||
|
|
||||||
dkimDir string
|
opendkimDir string
|
||||||
dkimSelectorDef string
|
dkimSelectorDef string
|
||||||
|
|
||||||
saslDBPath string
|
saslDBPath string
|
||||||
saslRealm string
|
saslRealm string
|
||||||
postfixDir string
|
postfixDir string
|
||||||
deployRoot 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
|
inboundEnabled bool
|
||||||
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
|
dmarcEnabled bool
|
||||||
// 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 {
|
func loadConfig() config {
|
||||||
@@ -134,11 +99,6 @@ func loadConfig() config {
|
|||||||
return config{
|
return config{
|
||||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
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
|
// Postfix's delivery log, under /data so the lines that resolve a
|
||||||
// "queued" send-log row outlive the container. The default must match
|
// "queued" send-log row outlive the container. The default must match
|
||||||
// maillog_file in build/postfix-config.sh.
|
// maillog_file in build/postfix-config.sh.
|
||||||
@@ -153,7 +113,6 @@ func loadConfig() config {
|
|||||||
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||||
timeZone: os.Getenv("TZ"),
|
|
||||||
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
||||||
// development-only escape hatch for testing over plain HTTP.
|
// development-only escape hatch for testing over plain HTTP.
|
||||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||||
@@ -177,12 +136,12 @@ func loadConfig() config {
|
|||||||
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||||
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||||
|
|
||||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
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
|
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||||
// the selector table, the layout entrypoint.sh prepares.
|
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||||
dkimDir: envDefault("DKIM_DIR", filepath.Join(dataDir, "dkim")),
|
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||||
|
|
||||||
// Application SASL accounts and the Postfix sender map (architecture.md §
|
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||||
@@ -193,45 +152,17 @@ func loadConfig() config {
|
|||||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||||
saslRealm: saslRealm(),
|
saslRealm: saslRealm(),
|
||||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||||
|
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
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".
|
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||||
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "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".
|
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "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
|
// 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
|
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||||
// login resolves to the right account. Postfix 3.10.13,
|
// login resolves to the right account.
|
||||||
// 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.
|
|
||||||
func saslRealm() string {
|
func saslRealm() string {
|
||||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||||
return r
|
return r
|
||||||
@@ -276,17 +207,10 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.Contains(cidr, "/") {
|
if !strings.Contains(cidr, "/") {
|
||||||
// The prefix length goes with how the address is written: an
|
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||||
// 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 {
|
|
||||||
cidr += "/32"
|
cidr += "/32"
|
||||||
|
} else {
|
||||||
|
cidr += "/128"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, n, err := net.ParseCIDR(cidr)
|
_, n, err := net.ParseCIDR(cidr)
|
||||||
@@ -299,73 +223,44 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
|||||||
return nets
|
return nets
|
||||||
}
|
}
|
||||||
|
|
||||||
// openData opens the database the panel runs on. When a backup was unpacked
|
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||||
// into /data, the restore guard comes first (architecture.md § Persistence):
|
// first fatal error from any role. A signal triggers a clean stop of all
|
||||||
// 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
|
|
||||||
// roles; a role error cancels the others and is returned so the process exits
|
// roles; a role error cancels the others and is returned so the process exits
|
||||||
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
||||||
// Image and processes).
|
// Image and processes).
|
||||||
func run() error {
|
func run() error {
|
||||||
cfg := loadConfig()
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
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.
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// One database handle shared by every role. The store serialises writes
|
// One database handle shared by every role. The store serialises writes
|
||||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||||
// can all use it without stepping on each other under WAL.
|
// can all use it without stepping on each other under WAL.
|
||||||
st, restored, err := openData(cfg, func(st *store.Store) error {
|
st, err := store.Open(cfg.dbPath)
|
||||||
return resyncAfterRestore(cfg, st, false)
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if !restored && cfg.dmarcEnabled {
|
if restored {
|
||||||
|
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||||
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if cfg.dmarcEnabled {
|
||||||
ms := newMailStack(cfg, st)
|
ms := newMailStack(cfg, st)
|
||||||
ms.pf.SetReloadHook(func() error { return nil })
|
ms.pf.SetReloadHook(func() error { return nil })
|
||||||
if err := ms.DMARC.Resync(); err != nil {
|
if err := ms.DMARC.Resync(); err != nil {
|
||||||
@@ -373,46 +268,12 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||||
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
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
|
var wg sync.WaitGroup
|
||||||
// Room for every role to report a failure without blocking: eight at most.
|
errc := make(chan error, 4)
|
||||||
errc := make(chan error, 8)
|
|
||||||
|
|
||||||
roles := []struct {
|
roles := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -420,8 +281,6 @@ func run() error {
|
|||||||
}{
|
}{
|
||||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(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 {
|
{"log-tailer", func(ctx context.Context) error {
|
||||||
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||||
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||||
@@ -430,20 +289,12 @@ func run() error {
|
|||||||
return cfg.retentionDays
|
return cfg.retentionDays
|
||||||
}
|
}
|
||||||
return days
|
return days
|
||||||
}, inboundTail, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
|
})
|
||||||
}},
|
}},
|
||||||
{"rate-limit-recalc", func(ctx context.Context) error {
|
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||||
return nil
|
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) }})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range roles {
|
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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,7 @@ import (
|
|||||||
// below stubs that story out — the archive is downloaded from a running panel
|
// 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
|
// 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:
|
// is started on the result through the same startup sequence run() uses:
|
||||||
// openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
|
// CheckRestore, store.Open, newPanel, Start.
|
||||||
// Start.
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restorePassword = "correct-horse-battery"
|
restorePassword = "correct-horse-battery"
|
||||||
@@ -75,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
|||||||
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "")
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
for _, want := range []string{restoreDomain, restoreSubject} {
|
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||||
@@ -85,9 +84,9 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
|||||||
// The archive carries the daemon files, and the first boot after restore
|
// 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.
|
// re-derives the maps from SQLite so they stay aligned with the database.
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
} {
|
} {
|
||||||
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
|||||||
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "")
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
|
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
|||||||
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||||
|
|
||||||
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
if !strings.Contains(body, restoreSubject) {
|
if !strings.Contains(body, restoreSubject) {
|
||||||
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||||
}
|
}
|
||||||
@@ -172,17 +171,22 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
|||||||
t.Fatalf("write manifest: %v", err)
|
t.Fatalf("write manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A record of no files: this test is about the maps, and the files it
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
// seeded were never in an archive.
|
|
||||||
writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
|
|
||||||
|
|
||||||
st, restored, err := openData(cfg, testResync(cfg))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open the restored data: %v", err)
|
t.Fatalf("CheckRestore: %v", err)
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
t.Fatal("CheckRestore did not report a restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
if !restored {
|
|
||||||
t.Fatal("the start did not take the data directory for a restore")
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := os.ReadFile(mapPath)
|
got, err := os.ReadFile(mapPath)
|
||||||
@@ -268,10 +272,10 @@ func seedPanelProject(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for path, content := range map[string]string{
|
for path, content := range map[string]string{
|
||||||
filepath.Join("dkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||||
} {
|
} {
|
||||||
full := filepath.Join(dataDir, path)
|
full := filepath.Join(dataDir, path)
|
||||||
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||||
@@ -322,17 +326,28 @@ func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := panelConfig(t, deployRoot)
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
st, restored, err := openData(cfg, testResync(cfg))
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = st.Close() })
|
|
||||||
if restored {
|
if restored {
|
||||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
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")
|
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the restored database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
if restored {
|
||||||
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
panel, err := newPanel(cfg, st)
|
panel, err := newPanel(cfg, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build the panel: %v", err)
|
t.Fatalf("build the panel: %v", err)
|
||||||
@@ -360,17 +375,16 @@ func signIn(t *testing.T, h http.Handler) *http.Cookie {
|
|||||||
|
|
||||||
// downloadBackup takes a backup through the panel's own /backup route, the way
|
// 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 operator does. An empty password downloads the plain archive; otherwise
|
||||||
// the response is a .spbk envelope, which is decrypted here. The account
|
// the response is a .spbk envelope, which is decrypted here.
|
||||||
// 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 {
|
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
form := url.Values{"account_password": {restorePassword}}
|
form := url.Values{}
|
||||||
if password != "" {
|
if password != "" {
|
||||||
form.Set("encrypt", "1")
|
form.Set("encrypt", "1")
|
||||||
form.Set("password", password)
|
form.Set("password", password)
|
||||||
form.Set("password_confirm", password)
|
form.Set("password_confirm", password)
|
||||||
}
|
}
|
||||||
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
|
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -403,15 +417,6 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
|||||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||||
// directory before the image is started.
|
// directory before the image is started.
|
||||||
func extract(t *testing.T, archive []byte, dir string) {
|
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()
|
t.Helper()
|
||||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -421,9 +426,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
|||||||
for {
|
for {
|
||||||
hdr, err := tr.Next()
|
hdr, err := tr.Next()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
if cut != "" {
|
|
||||||
t.Fatalf("the archive has no entry %s to cut", cut)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -434,10 +436,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
|||||||
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
var body io.Reader = tr
|
|
||||||
if hdr.Name == cut {
|
|
||||||
body = io.LimitReader(tr, cutAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch hdr.Typeflag {
|
switch hdr.Typeflag {
|
||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
@@ -452,7 +450,7 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create %s: %v", path, err)
|
t.Fatalf("create %s: %v", path, err)
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(f, body); err != nil {
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
t.Fatalf("write %s: %v", path, err)
|
t.Fatalf("write %s: %v", path, err)
|
||||||
}
|
}
|
||||||
@@ -460,28 +458,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
|
|||||||
t.Fatalf("close %s: %v", path, err)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -21,21 +21,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
|
||||||
"github.com/mixeme/selfpost/internal/backup"
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
"github.com/mixeme/selfpost/internal/secretfile"
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
"github.com/mixeme/selfpost/internal/web/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// passwordEnv names the environment variable holding the encryption password.
|
// passwordEnv names the environment variable holding the encryption password.
|
||||||
@@ -75,20 +70,13 @@ func run(outPath, password string) error {
|
|||||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
|
||||||
// The floor the panel holds the same files to: an encrypted backup is
|
w, closeOut, err := openOutput(outPath)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer w.abort()
|
defer closeOut()
|
||||||
|
|
||||||
var sink io.Writer = w
|
sink := w
|
||||||
var env *secretfile.Writer
|
var env *secretfile.Writer
|
||||||
if password != "" {
|
if password != "" {
|
||||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||||
@@ -114,9 +102,6 @@ func run(outPath, password string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := w.commit(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if outPath != "" {
|
if outPath != "" {
|
||||||
kind := "plain"
|
kind := "plain"
|
||||||
if password != "" {
|
if password != "" {
|
||||||
@@ -148,28 +133,17 @@ func runDecrypt(inPath, outPath, password string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if env.Type() != secretfile.TypeFullBackup {
|
||||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer w.abort()
|
defer closeOut()
|
||||||
|
|
||||||
if _, err := io.Copy(w, plain); err != nil {
|
if _, err := io.Copy(w, env); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := w.commit(); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if outPath != "" {
|
if outPath != "" {
|
||||||
@@ -178,61 +152,17 @@ func runDecrypt(inPath, outPath, password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// output is where the CLI writes: stdout, or a file that takes its name only
|
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||||
// once it is whole. A backup or a decryption that fails half way - a wrong
|
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||||
// password, an envelope cut short - leaves whatever had the name before, and
|
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||||
// 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) {
|
|
||||||
if outPath == "" {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return &output{Writer: f, tmp: f, path: outPath}, nil
|
return f, func() { _ = f.Close() }, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPassword takes the password from the given file (first line) or, when no
|
// readPassword takes the password from the given file (first line) or, when no
|
||||||
|
|||||||
@@ -162,76 +162,3 @@ func TestReadPassword(t *testing.T) {
|
|||||||
t.Error("-decrypt without a password was accepted")
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ DMARC_REPORTS_ENABLE=false
|
|||||||
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||||
# INBOUND_ANTISPAM_MILTER_ACTION=accept
|
# 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).
|
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
|
||||||
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||||
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
||||||
@@ -60,12 +56,6 @@ PANEL_SESSION_IDLE_DAYS=7
|
|||||||
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
||||||
# SELFPOST_DNS_RESOLVERS=
|
# 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
|
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||||
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||||
# you know the exact address of your reverse proxy — trusting the wrong
|
# you know the exact address of your reverse proxy — trusting the wrong
|
||||||
|
|||||||
@@ -9,11 +9,8 @@
|
|||||||
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
|
# 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.
|
# 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"
|
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
|
||||||
# are rebuilt every night - and it is the build the comments in
|
# deliberately. Default milter action is fail-open (accept) so a down sidecar
|
||||||
# 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
|
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||||
# defer instead.
|
# defer instead.
|
||||||
|
|
||||||
@@ -27,30 +24,6 @@ services:
|
|||||||
- antispam
|
- antispam
|
||||||
|
|
||||||
antispam:
|
antispam:
|
||||||
# The milter: rspamd 4.2.2, conf/worker-proxy.inc, "milter = yes" for the
|
image: rspamd/rspamd:3.11
|
||||||
# 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
|
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.
|
# 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,17 @@
|
|||||||
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
||||||
# on the host running Apache — NOT inside the SelfPost container. Requires
|
# 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`).
|
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
||||||
#
|
#
|
||||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
||||||
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
||||||
# redirect vhost, and renews in the background via its own timer. The resulting
|
# redirect vhost, and renews in the background via its own timer. The resulting
|
||||||
# PEM files land at
|
# PEM files land at
|
||||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
|
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
|
||||||
# as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
|
# bind-mount that directory read-only into the SelfPost container (see
|
||||||
# to them and mount /etc/letsencrypt read-only at the same path (see
|
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||||
# ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
|
# can present the very same certificate on 465/587. One certificate, two
|
||||||
# present the very same certificate on 465/587. Mounting live/mail.example.com
|
# consumers — no separate cert just for the panel.
|
||||||
# alone gives the container links into a directory it does not have. One
|
|
||||||
# certificate, two consumers — no separate cert just for the panel.
|
|
||||||
|
|
||||||
<VirtualHost *:443>
|
<VirtualHost *:443>
|
||||||
ServerName mail.example.com
|
ServerName mail.example.com
|
||||||
|
|||||||
@@ -11,19 +11,10 @@
|
|||||||
# storage detail, not a documented public API — VERIFY the exact path against
|
# 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'`)
|
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||||
# before wiring it into the volume mount below.
|
# 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
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
# resolves every relative path in both files against the directory of the
|
# 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/... -
|
# 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"):
|
|
||||||
#
|
#
|
||||||
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||||
|
|
||||||
@@ -47,12 +38,8 @@ services:
|
|||||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
# Pinned to a release: "2" moves under a running server (security audit
|
image: caddy:2
|
||||||
# 2026-10-17, I4). Bump it deliberately.
|
|
||||||
image: caddy:2.11.7
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
|
||||||
pids_limit: 256
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# on the HOST (the target audience for this project typically runs Apache there
|
# 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
|
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
||||||
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
# 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
|
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
|
||||||
# this compose file mounts.
|
# this compose file mounts.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -18,12 +18,10 @@
|
|||||||
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
# 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
|
# version against the running binary's version, so ":latest" would make that
|
||||||
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
# 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.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:2.16.0
|
image: ghcr.io/mixeme/selfpost:1.9.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
@@ -49,16 +47,12 @@ services:
|
|||||||
# How long the send log keeps rows before the background sweep deletes
|
# How long the send log keeps rows before the background sweep deletes
|
||||||
# them — the main driver of /data growth over time.
|
# them — the main driver of /data growth over time.
|
||||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
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.
|
||||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
# 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
|
# report what the internet sees rather than what this host's own stub
|
||||||
# resolver synthesises. Empty uses public defaults.
|
# resolver synthesises. Empty uses public defaults.
|
||||||
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
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
|
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
||||||
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
||||||
# client spoof its rate-limit key.
|
# client spoof its rate-limit key.
|
||||||
@@ -75,15 +69,9 @@ services:
|
|||||||
# certs/ are archived alongside /data (architecture.md § Persistence).
|
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||||
- .:/selfpost-deploy:ro
|
- .:/selfpost-deploy:ro
|
||||||
# Read-only: SelfPost only ever reads certificates, never manages them.
|
# Read-only: SelfPost only ever reads certificates, never manages them.
|
||||||
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
|
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
|
||||||
# links to where your reverse proxy's certbot keeps them.
|
# /etc/letsencrypt on the host.
|
||||||
- ./certs:/etc/postfix/tls:ro
|
- ./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:
|
ports:
|
||||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||||
# published directly — mail traffic bypasses Apache entirely, it only ever
|
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||||
@@ -93,22 +81,20 @@ services:
|
|||||||
# reachable from the internet without TLS.
|
# reachable from the internet without TLS.
|
||||||
- "465:465"
|
- "465:465"
|
||||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
# 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"
|
- "587:587"
|
||||||
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||||
# when the variable is true.
|
# when the variable is true.
|
||||||
- "25:25"
|
- "25:25"
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
# Hardening. SelfPost's entrypoint runs as root to fix /data ownership
|
# Hardening. SelfPost's entrypoint still needs to run as
|
||||||
# and normalise permissions under the shared `selfpost` group (see
|
# root very briefly to fix /data ownership and normalise permissions
|
||||||
# build/entrypoint.sh) before supervisord drops the panel to an
|
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||||
# unprivileged user; supervisord, Postfix's master, cert-reload and
|
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||||
# logrotate stay root for the container's life, which is inherent to
|
|
||||||
# them (security audit 2026-10-17, I3) — so this cannot be
|
|
||||||
# `user: panel` or a fully read-only rootfs without breaking that startup
|
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||||
# self-healing. What IS applied: no privilege escalation past what the
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
# image already grants, and every Linux capability dropped except the
|
# image already grants, and every Linux capability dropped except the
|
||||||
# small set the root startup phase and Postfix genuinely need:
|
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||||
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
||||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||||
@@ -118,28 +104,15 @@ services:
|
|||||||
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
||||||
# `panel` user; Postfix switches to its own users;
|
# `panel` user; Postfix switches to its own users;
|
||||||
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
||||||
# - KILL — root signals processes of other uids: supervisord
|
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
|
||||||
# stops and restarts the panel (supervisor 4.2.5,
|
# different uid) on domain add/remove to reload its
|
||||||
# supervisor/process.py:467, options.kill()), and
|
# KeyTable/SigningTable; without it os.kill() fails
|
||||||
# Postfix's master ends its children, which run as
|
# with EPERM even though the caller is root, because
|
||||||
# `postfix` (Postfix 3.10.13, src/master/master_sig.c:196,
|
# the kernel's signal permission check for
|
||||||
# master_sigdeath(); master_spawn.c:365,
|
# cross-uid kill() still consults CAP_KILL.
|
||||||
# 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.
|
|
||||||
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||||
# self-healing; without them chmod fails with EPERM and the container
|
# self-healing; without them chmod fails with EPERM and the container
|
||||||
# crash-loops on start.
|
# 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:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
@@ -153,9 +126,3 @@ services:
|
|||||||
- SETGID
|
- SETGID
|
||||||
- DAC_OVERRIDE
|
- DAC_OVERRIDE
|
||||||
- KILL
|
- 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
|
|
||||||
|
|||||||
@@ -8,21 +8,14 @@
|
|||||||
# takes over 80/443 and proxies to the panel over the compose network
|
# 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
|
# 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
|
# (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/... -
|
# 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"):
|
|
||||||
#
|
#
|
||||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
# 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 -
|
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||||
# run once, with port 80 free (certbot answers the challenge itself;
|
# serve — run once):
|
||||||
# --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):
|
|
||||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
# 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
|
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -36,19 +29,14 @@ services:
|
|||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- .:/selfpost-deploy:ro
|
- .:/selfpost-deploy:ro
|
||||||
# certs/ holds two links, fullchain.pem and privkey.pem, to
|
# Same host directory certbot below writes into — plain bind mount, no
|
||||||
# /etc/letsencrypt/live/mail.example.com/ - a path inside the
|
# named volume, so the PEM files are as directly inspectable as in the
|
||||||
# container: certbot's directory below is mounted there whole, because
|
# Apache scenario.
|
||||||
# its live/ files are relative links into archive/ (mounting live/ alone
|
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||||
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
|
|
||||||
- ./certs:/etc/postfix/tls:ro
|
|
||||||
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27
|
image: nginx:1.27
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
|
||||||
pids_limit: 256
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
ports:
|
ports:
|
||||||
@@ -60,20 +48,10 @@ services:
|
|||||||
- ./nginx/certbot-www:/var/www/certbot:ro
|
- ./nginx/certbot-www:/var/www/certbot:ro
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
# Pinned like every image here: "latest" moves under a running server
|
image: certbot/certbot:latest
|
||||||
# (security audit 2026-10-17, I4). Bump it deliberately.
|
|
||||||
image: certbot/certbot:v5.8.0
|
|
||||||
pids_limit: 64
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/certbot-etc:/etc/letsencrypt
|
- ./nginx/certbot-etc:/etc/letsencrypt
|
||||||
- ./nginx/certbot-www:/var/www/certbot
|
- ./nginx/certbot-www:/var/www/certbot
|
||||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||||
# until a certificate is within its renewal window.
|
# until a certificate is within its renewal window.
|
||||||
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
|
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||||
# 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'
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
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;
|
http2 on;
|
||||||
server_name mail.example.com;
|
server_name mail.example.com;
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
#
|
#
|
||||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
# resolves every relative path in both files against the directory of the
|
# 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/... -
|
# 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"):
|
|
||||||
#
|
#
|
||||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||||
|
|
||||||
@@ -27,10 +25,7 @@ services:
|
|||||||
# Traefik only proxies the panel; it never sees the mail ports.
|
# Traefik only proxies the panel; it never sees the mail ports.
|
||||||
# These live on the selfpost service (not traefik's) because the
|
# These live on the selfpost service (not traefik's) because the
|
||||||
# docker provider reads routing labels off the container being routed
|
# docker provider reads routing labels off the container being routed
|
||||||
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
|
# to, not off traefik itself.
|
||||||
# docker/, "Routing Configuration": "Traefik uses container labels";
|
|
||||||
# with exposedByDefault=false only containers labelled
|
|
||||||
# traefik.enable=true.)
|
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||||
@@ -40,8 +35,6 @@ services:
|
|||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
|
||||||
pids_limit: 256
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -1,75 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
# 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.
|
# 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
|
# 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
|
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
||||||
# by SelfPost/Postfix.
|
# by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
#
|
set -eu
|
||||||
# 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
|
|
||||||
|
|
||||||
ACME_JSON="${1:?path to acme.json}"
|
ACME_JSON="${1:?path to acme.json}"
|
||||||
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||||
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
umask 077
|
|
||||||
|
|
||||||
die() {
|
jq -r --arg domain "$DOMAIN" '
|
||||||
echo "extract-cert: $*" >&2
|
.le.Certificates[]
|
||||||
exit 1
|
| 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
|
jq -r --arg domain "$DOMAIN" '
|
||||||
# nothing.
|
.le.Certificates[]
|
||||||
field() {
|
| select(.domain.main == $domain)
|
||||||
jq -r --arg domain "$DOMAIN" --arg field "$1" '
|
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||||
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
|
|
||||||
' "$ACME_JSON"
|
|
||||||
}
|
|
||||||
|
|
||||||
cert_b64=$(field certificate)
|
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||||
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
|
|
||||||
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.org · Outbound domains · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.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>
|
||||||
|
|
||||||
|
<header class="sp-bar">
|
||||||
|
<div class="sp-wrap">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="sp-sections">
|
||||||
|
<li><a href="status.html">Overview</a></li>
|
||||||
|
<li><a href="domain.html" aria-current="true">Outbound</a></li>
|
||||||
|
<li><a href="#">Inbound</a></li>
|
||||||
|
<li><a href="#">Server</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="sp-user">
|
||||||
|
<li>
|
||||||
|
<details class="dropdown">
|
||||||
|
<summary>admin</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
<li><a href="#">Account</a></li>
|
||||||
|
<li><a href="#">Help</a></li>
|
||||||
|
<li><a href="login.html">Sign out</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="sp-pages">
|
||||||
|
<div class="sp-wrap">
|
||||||
|
<a href="#" aria-current="true">Domains</a>
|
||||||
|
<a href="#">Log</a>
|
||||||
|
<a href="#">Queue</a>
|
||||||
|
<a href="#">DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="sp-wrap">
|
||||||
|
<div class="sp-head">
|
||||||
|
<div>
|
||||||
|
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
|
||||||
|
<h1>example.org</h1>
|
||||||
|
</div>
|
||||||
|
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="sp-sheet">
|
||||||
|
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
|
||||||
|
<div class="sp-body sp-cols">
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Server</dt>
|
||||||
|
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
</dl>
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Ports</dt>
|
||||||
|
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sp-sheet">
|
||||||
|
<header>
|
||||||
|
<span class="sp-no">02</span><h2>DNS records</h2>
|
||||||
|
<small>Checked 3 min ago</small>
|
||||||
|
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||||
|
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||||
|
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
|
||||||
|
</dl>
|
||||||
|
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
|
||||||
|
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
|
||||||
|
</dl>
|
||||||
|
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sp-sheet">
|
||||||
|
<header>
|
||||||
|
<span class="sp-no">03</span><h2>Applications</h2>
|
||||||
|
<small>SASL logins that may send as this domain</small>
|
||||||
|
<a href="#" role="button" class="sp-btn-sm">Add application</a>
|
||||||
|
</header>
|
||||||
|
<table class="sp-apps">
|
||||||
|
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="sp-login">prod-server</td>
|
||||||
|
<td>*@example.org</td>
|
||||||
|
<td>1 102 msg · peak 96/h</td>
|
||||||
|
<td><span class="sp-tag ok">200 / h</span></td>
|
||||||
|
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="sp-login">alerts</td>
|
||||||
|
<td>alerts@example.org, noc@example.org</td>
|
||||||
|
<td>182 msg · peak 14/h</td>
|
||||||
|
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
|
||||||
|
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sp-sheet sp-ledger">
|
||||||
|
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
|
||||||
|
<details>
|
||||||
|
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
|
||||||
|
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
|
||||||
|
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
|
||||||
|
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
|
||||||
|
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="sp-foot">
|
||||||
|
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="../shared/brand.css">
|
||||||
|
<link rel="stylesheet" href="theme.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="sp-signin">
|
||||||
|
<div class="sp-signin-card">
|
||||||
|
<img src="../../selfpost-stamp.svg" alt="SelfPost">
|
||||||
|
<section class="sp-sheet">
|
||||||
|
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
|
||||||
|
<div class="sp-body">
|
||||||
|
<form method="post" action="status.html">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="sp-wrap sp-foot">
|
||||||
|
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Overview · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.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>
|
||||||
|
|
||||||
|
<header class="sp-bar">
|
||||||
|
<div class="sp-wrap">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="sp-sections">
|
||||||
|
<li><a href="status.html" aria-current="page">Overview</a></li>
|
||||||
|
<li><a href="domain.html">Outbound</a></li>
|
||||||
|
<li><a href="#">Inbound</a></li>
|
||||||
|
<li><a href="#">Server</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="sp-user">
|
||||||
|
<li>
|
||||||
|
<details class="dropdown">
|
||||||
|
<summary>admin</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
<li><a href="#">Account</a></li>
|
||||||
|
<li><a href="#">Help</a></li>
|
||||||
|
<li><a href="login.html">Sign out</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="sp-wrap">
|
||||||
|
<div class="sp-head">
|
||||||
|
<div>
|
||||||
|
<p class="sp-kicker">mail.example.org</p>
|
||||||
|
<h1>Overview</h1>
|
||||||
|
</div>
|
||||||
|
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="sp-sheet sp-verdict">
|
||||||
|
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
|
||||||
|
<div>
|
||||||
|
<h2>Mail is flowing. One thing needs attention.</h2>
|
||||||
|
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sp-sheet sp-ledger">
|
||||||
|
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
|
||||||
|
<div class="sp-detail">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
|
||||||
|
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
|
||||||
|
<div class="sp-detail">
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
|
||||||
|
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
||||||
|
</dl>
|
||||||
|
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
|
||||||
|
<div class="sp-detail">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
|
||||||
|
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
|
||||||
|
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
|
||||||
|
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
|
||||||
|
<div class="sp-detail">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
|
||||||
|
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
|
||||||
|
<div class="sp-detail">
|
||||||
|
<dl class="sp-kv">
|
||||||
|
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
|
||||||
|
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
|
||||||
|
</dl>
|
||||||
|
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="sp-foot">
|
||||||
|
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
|
||||||
|
control (type, buttons, inputs, tables, details, dropdown); this file maps
|
||||||
|
the brand onto Pico's variables and adds only what is SelfPost's own:
|
||||||
|
the two-row header, the sheet, numbered form sections, the postmark and
|
||||||
|
the DNS record block. */
|
||||||
|
|
||||||
|
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
|
||||||
|
palette on :root:not([data-theme=dark]), which a bare attribute selector
|
||||||
|
loses to. */
|
||||||
|
:root[data-theme=light] {
|
||||||
|
--pico-font-family-sans-serif: var(--sp-sans);
|
||||||
|
--pico-font-family-monospace: var(--sp-mono);
|
||||||
|
--pico-font-family: var(--sp-sans);
|
||||||
|
--pico-border-radius: 2px;
|
||||||
|
--pico-spacing: .9rem;
|
||||||
|
--pico-typography-spacing-vertical: .9rem;
|
||||||
|
--pico-form-element-spacing-vertical: .5rem;
|
||||||
|
--pico-form-element-spacing-horizontal: .75rem;
|
||||||
|
|
||||||
|
--pico-background-color: var(--sp-paper);
|
||||||
|
--pico-color: var(--sp-ink);
|
||||||
|
--pico-muted-color: var(--sp-muted);
|
||||||
|
--pico-muted-border-color: var(--sp-rule);
|
||||||
|
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
|
||||||
|
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
|
||||||
|
|
||||||
|
--pico-primary: var(--sp-brick);
|
||||||
|
--pico-primary-background: var(--sp-brick);
|
||||||
|
--pico-primary-border: var(--sp-brick);
|
||||||
|
--pico-primary-underline: rgb(122 59 46 / .35);
|
||||||
|
--pico-primary-hover: var(--sp-brick-dark);
|
||||||
|
--pico-primary-hover-background: var(--sp-brick-dark);
|
||||||
|
--pico-primary-hover-border: var(--sp-brick-dark);
|
||||||
|
--pico-primary-hover-underline: var(--sp-brick-dark);
|
||||||
|
--pico-primary-focus: rgb(122 59 46 / .25);
|
||||||
|
--pico-primary-inverse: #fff;
|
||||||
|
|
||||||
|
--pico-secondary: var(--sp-ink-2);
|
||||||
|
--pico-secondary-background: var(--sp-ink-2);
|
||||||
|
--pico-secondary-border: var(--sp-ink-2);
|
||||||
|
--pico-secondary-hover: var(--sp-ink);
|
||||||
|
--pico-secondary-hover-background: var(--sp-ink);
|
||||||
|
--pico-secondary-hover-border: var(--sp-ink);
|
||||||
|
--pico-secondary-focus: rgb(18 22 28 / .2);
|
||||||
|
--pico-secondary-inverse: #fff;
|
||||||
|
|
||||||
|
--pico-form-element-background-color: #fff;
|
||||||
|
--pico-form-element-border-color: var(--sp-rule-strong);
|
||||||
|
--pico-form-element-active-border-color: var(--sp-brick);
|
||||||
|
--pico-form-element-focus-color: rgb(122 59 46 / .25);
|
||||||
|
--pico-table-border-color: var(--sp-rule);
|
||||||
|
--pico-code-background-color: var(--sp-well);
|
||||||
|
--pico-code-color: var(--sp-ink);
|
||||||
|
--pico-dropdown-background-color: #fff;
|
||||||
|
--pico-dropdown-border-color: var(--sp-rule);
|
||||||
|
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
|
||||||
|
one size everywhere. */
|
||||||
|
:root { font-size: 15px; }
|
||||||
|
body { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
|
||||||
|
small, .sp-muted { color: var(--sp-muted); }
|
||||||
|
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
|
||||||
|
|
||||||
|
/* ---- header: sections on the first row, the section's pages on the second */
|
||||||
|
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
|
||||||
|
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
|
||||||
|
.sp-bar nav { align-items: center; }
|
||||||
|
.sp-bar nav li { padding-block: .45rem; }
|
||||||
|
.sp-brand img { height: 46px; width: auto; display: block; }
|
||||||
|
.sp-sections a {
|
||||||
|
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
|
||||||
|
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
|
||||||
|
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
|
||||||
|
}
|
||||||
|
.sp-sections a:hover { color: var(--sp-ink); }
|
||||||
|
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
|
||||||
|
.sp-user summary { font-size: .9rem; }
|
||||||
|
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
|
||||||
|
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
|
||||||
|
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
|
||||||
|
.sp-pages a:hover { color: var(--sp-ink); }
|
||||||
|
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
|
||||||
|
|
||||||
|
/* ---- the sheet: one column, one width, every page */
|
||||||
|
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
|
||||||
|
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
|
||||||
|
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
|
||||||
|
.sp-head .sp-kicker a { color: inherit; }
|
||||||
|
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
.sp-head-actions > * { margin: 0; }
|
||||||
|
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
|
||||||
|
.sp-sheet > header {
|
||||||
|
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
|
||||||
|
border-bottom: 1px solid var(--sp-rule);
|
||||||
|
}
|
||||||
|
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
|
||||||
|
.sp-sheet > header h2 {
|
||||||
|
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; margin: 0; flex: 1;
|
||||||
|
}
|
||||||
|
.sp-sheet > header small { font-size: .85rem; }
|
||||||
|
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
|
||||||
|
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
|
||||||
|
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
|
||||||
|
.sp-foot a { color: inherit; }
|
||||||
|
|
||||||
|
/* ---- status tag and the postmark */
|
||||||
|
.sp-tag {
|
||||||
|
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
|
||||||
|
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
|
||||||
|
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
|
||||||
|
border-radius: 2px; white-space: nowrap; text-decoration: none;
|
||||||
|
}
|
||||||
|
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
|
||||||
|
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
|
||||||
|
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
|
||||||
|
|
||||||
|
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
|
||||||
|
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
|
||||||
|
.sp-verdict p { margin: 0; color: var(--sp-muted); }
|
||||||
|
.sp-postmark {
|
||||||
|
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
|
||||||
|
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
|
||||||
|
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
|
||||||
|
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
|
||||||
|
color: var(--sp-ok);
|
||||||
|
}
|
||||||
|
.sp-postmark.warn { color: var(--sp-warn); }
|
||||||
|
.sp-postmark.fail { color: var(--sp-fail); }
|
||||||
|
.sp-postmark b { font-size: 1.25rem; display: block; }
|
||||||
|
.sp-postmark span { font-size: .55rem; display: block; }
|
||||||
|
|
||||||
|
/* ---- ledger: one row per check, the row opens into its detail */
|
||||||
|
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
|
||||||
|
.sp-ledger details:last-child { border-bottom: 0; }
|
||||||
|
.sp-ledger summary {
|
||||||
|
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
|
||||||
|
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
|
||||||
|
}
|
||||||
|
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
|
||||||
|
.sp-ledger summary:hover { background: var(--sp-paper); }
|
||||||
|
.sp-ledger summary strong { font-weight: 600; }
|
||||||
|
.sp-ledger summary span.sp-muted { font-size: .93rem; }
|
||||||
|
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
|
||||||
|
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
|
||||||
|
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
|
||||||
|
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
|
||||||
|
|
||||||
|
/* ---- label / value / copy: the DNS record block and connection settings */
|
||||||
|
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
|
||||||
|
.sp-record:last-child { border-bottom: 0; }
|
||||||
|
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
|
||||||
|
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
|
||||||
|
.sp-record-head small { margin-left: auto; }
|
||||||
|
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
|
||||||
|
.sp-kv dt {
|
||||||
|
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
color: var(--sp-muted); padding-top: .5rem; margin: 0;
|
||||||
|
}
|
||||||
|
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
|
||||||
|
.sp-value {
|
||||||
|
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
|
||||||
|
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
|
||||||
|
overflow-wrap: anywhere; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
|
||||||
|
.sp-copy {
|
||||||
|
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
|
||||||
|
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
|
||||||
|
}
|
||||||
|
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
|
||||||
|
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
|
||||||
|
.sp-note.fail { color: var(--sp-fail); }
|
||||||
|
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
|
||||||
|
|
||||||
|
/* ---- applications */
|
||||||
|
.sp-apps { margin: 0; font-size: .95rem; }
|
||||||
|
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
|
||||||
|
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
|
||||||
|
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
|
||||||
|
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
|
||||||
|
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
|
||||||
|
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
|
||||||
|
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
|
||||||
|
|
||||||
|
/* ---- sign-in */
|
||||||
|
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
|
||||||
|
.sp-signin-card { width: 23rem; max-width: 100%; }
|
||||||
|
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
|
||||||
|
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
|
||||||
|
.sp-signin-card button { margin-bottom: 0; }
|
||||||
|
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
|
||||||
|
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
|
||||||
|
.sp-ledger summary::after { grid-column: 3; }
|
||||||
|
.sp-ledger .sp-detail { padding-left: 1.2rem; }
|
||||||
|
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
|
||||||
|
.sp-kv dt { padding-top: .5rem; }
|
||||||
|
.sp-note { margin-left: 0; }
|
||||||
|
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.org · Outbound domains · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.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="page">
|
||||||
|
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||||
|
<div class="navbar-collapse collapse show">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Outbound</li>
|
||||||
|
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Inbound</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Server</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav sp-nav-foot">
|
||||||
|
<li class="sp-nav-user">Signed in as admin</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
|
||||||
|
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="btn-list">
|
||||||
|
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
|
||||||
|
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row row-cards">
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
|
||||||
|
|
||||||
|
<div class="card-body sp-record">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
|
||||||
|
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-9">
|
||||||
|
<label class="form-label">Host / name</label>
|
||||||
|
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<input class="form-control" readonly value="TXT">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Value to publish</label>
|
||||||
|
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body sp-record">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
|
||||||
|
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-9">
|
||||||
|
<label class="form-label">Host / name</label>
|
||||||
|
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<input class="form-control" readonly value="TXT">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Value to publish</label>
|
||||||
|
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">In DNS now</label>
|
||||||
|
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
|
||||||
|
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body sp-record">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
|
||||||
|
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-9">
|
||||||
|
<label class="form-label">Host / name</label>
|
||||||
|
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<input class="form-control" readonly value="TXT">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Value to publish</label>
|
||||||
|
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title">Connection</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="datagrid">
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="datagrid">
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||||
|
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||||
|
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
|
||||||
|
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
|
||||||
|
<td><span class="badge bg-green-lt">200 / h</span></td>
|
||||||
|
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
|
||||||
|
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
|
||||||
|
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer footer-transparent">
|
||||||
|
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
|
||||||
|
<link rel="stylesheet" href="../shared/brand.css">
|
||||||
|
<link rel="stylesheet" href="theme.css">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column">
|
||||||
|
<div class="page page-center">
|
||||||
|
<div class="container container-tight py-4">
|
||||||
|
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h2 text-center mb-4">Sign in</h2>
|
||||||
|
<form method="post" action="status.html">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="username">Username</label>
|
||||||
|
<input class="form-control" id="username" name="username" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label" for="password">Password</label>
|
||||||
|
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
|
||||||
|
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Overview · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.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="page">
|
||||||
|
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||||
|
<div class="navbar-collapse collapse show">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Outbound</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Inbound</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
|
||||||
|
|
||||||
|
<li class="sp-nav-label">Server</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav sp-nav-foot">
|
||||||
|
<li class="sp-nav-user">Signed in as admin</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<div class="page-pretitle">mail.example.org</div>
|
||||||
|
<h2 class="page-title">Overview</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<div class="alert alert-warning bg-white" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
|
||||||
|
<div>
|
||||||
|
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
|
||||||
|
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards mb-3">
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||||
|
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-deck row-cards">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
|
||||||
|
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
|
||||||
|
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
|
||||||
|
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
|
||||||
|
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
|
||||||
|
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
|
||||||
|
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="datagrid">
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead><tr><th>Milter</th><th>State</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
||||||
|
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="datagrid">
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
|
||||||
|
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer footer-transparent">
|
||||||
|
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
|
||||||
|
cards, tables, badges, input groups and the sign-in page as shipped; this
|
||||||
|
file only re-points Tabler's variables at the brand and adds the two things
|
||||||
|
Tabler has no word for: a sidebar group label and the DNS record block. */
|
||||||
|
|
||||||
|
:root, [data-bs-theme=light] {
|
||||||
|
--tblr-font-sans-serif: var(--sp-sans);
|
||||||
|
--tblr-font-monospace: var(--sp-mono);
|
||||||
|
--tblr-body-bg: var(--sp-paper);
|
||||||
|
--tblr-body-color: var(--sp-ink);
|
||||||
|
--tblr-secondary: var(--sp-muted);
|
||||||
|
--tblr-border-color: var(--sp-rule);
|
||||||
|
--tblr-border-radius: 3px;
|
||||||
|
--tblr-border-radius-lg: 4px;
|
||||||
|
|
||||||
|
--tblr-primary: #7a3b2e;
|
||||||
|
--tblr-primary-rgb: 122, 59, 46;
|
||||||
|
--tblr-link-color: #7a3b2e;
|
||||||
|
--tblr-link-color-rgb: 122, 59, 46;
|
||||||
|
--tblr-link-hover-color: #632f25;
|
||||||
|
--tblr-link-hover-color-rgb: 99, 47, 37;
|
||||||
|
|
||||||
|
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
|
||||||
|
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
|
||||||
|
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
|
||||||
|
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
|
||||||
|
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
|
||||||
|
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
|
||||||
|
}
|
||||||
|
body { font-feature-settings: normal; }
|
||||||
|
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
|
||||||
|
code, .font-monospace { font-family: var(--sp-mono); }
|
||||||
|
|
||||||
|
/* ---- sidebar: ink, with the stamp on its own paper */
|
||||||
|
.navbar-vertical {
|
||||||
|
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
|
||||||
|
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
|
||||||
|
--tblr-navbar-border-color: transparent;
|
||||||
|
}
|
||||||
|
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
|
||||||
|
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
|
||||||
|
.navbar-vertical .nav-link { padding-block: .42rem; }
|
||||||
|
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
|
||||||
|
.navbar-vertical .nav-item.active > .nav-link {
|
||||||
|
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
|
||||||
|
}
|
||||||
|
.sp-nav-label {
|
||||||
|
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
|
||||||
|
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
|
||||||
|
}
|
||||||
|
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
|
||||||
|
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
|
||||||
|
|
||||||
|
/* ---- page */
|
||||||
|
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
|
||||||
|
.card-title { font-weight: 600; }
|
||||||
|
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
|
||||||
|
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
|
||||||
|
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
|
||||||
|
.sp-tile .fw-semibold { line-height: 1.2; }
|
||||||
|
.sp-tile .status { margin-left: auto; }
|
||||||
|
progress.sp-bar {
|
||||||
|
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
|
||||||
|
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
|
||||||
|
}
|
||||||
|
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
|
||||||
|
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
|
||||||
|
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
|
||||||
|
|
||||||
|
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
|
||||||
|
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
|
||||||
|
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
|
||||||
|
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
|
||||||
|
}
|
||||||
|
.sp-record textarea { resize: none; }
|
||||||
|
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
|
||||||
|
|
||||||
|
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
|
||||||
|
.sp-signin-mark { width: 15rem; height: auto; }
|
||||||
|
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.org · Outbound domains · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-menu is-active">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<a class="navbar-item" href="status.html">Overview</a>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable sp-current">
|
||||||
|
<a class="navbar-link" href="domain.html">Outbound</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item is-active" href="domain.html">Domains</a>
|
||||||
|
<a class="navbar-item" href="#">Log</a>
|
||||||
|
<a class="navbar-item" href="#">Queue</a>
|
||||||
|
<a class="navbar-item" href="#">DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">Inbound</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="#">Domains</a>
|
||||||
|
<a class="navbar-item" href="#">Log</a>
|
||||||
|
<a class="navbar-item" href="#">Queue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">Server</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="#">System log</a>
|
||||||
|
<a class="navbar-item" href="#">Backup</a>
|
||||||
|
<a class="navbar-item" href="#">Users</a>
|
||||||
|
<a class="navbar-item" href="#">Settings</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">admin</a>
|
||||||
|
<div class="navbar-dropdown is-right">
|
||||||
|
<a class="navbar-item" href="#">Account</a>
|
||||||
|
<a class="navbar-item" href="#">Help</a>
|
||||||
|
<hr class="navbar-divider">
|
||||||
|
<a class="navbar-item" href="login.html">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container sp-page">
|
||||||
|
<div class="level mb-5">
|
||||||
|
<div class="level-left">
|
||||||
|
<div>
|
||||||
|
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
|
||||||
|
<h1 class="title is-3 mb-0">example.org</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-right">
|
||||||
|
<div class="buttons">
|
||||||
|
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
|
||||||
|
<a class="button is-primary" href="#">Add application</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<aside class="menu sp-side">
|
||||||
|
<p class="menu-label">This domain</p>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
|
||||||
|
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
|
||||||
|
<li><a href="#connection">Connection</a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="menu-label">Rarely changed</p>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a href="#">DMARC reports</a></li>
|
||||||
|
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
|
||||||
|
<li><a href="#">Export</a></li>
|
||||||
|
<li><a class="has-text-danger" href="#">Delete domain</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-9">
|
||||||
|
<div class="box" id="dns">
|
||||||
|
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||||
|
<div class="sp-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Host / name</label>
|
||||||
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">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="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
|
||||||
|
<div class="sp-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Host / name</label>
|
||||||
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">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"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">In DNS now</label>
|
||||||
|
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
|
||||||
|
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
|
||||||
|
<div class="sp-row">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Host / name</label>
|
||||||
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">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"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" id="apps">
|
||||||
|
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
|
||||||
|
<table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
|
||||||
|
<td><span class="tag is-success is-light">200 / h</span></td>
|
||||||
|
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
|
||||||
|
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
|
||||||
|
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box" id="connection">
|
||||||
|
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
|
||||||
|
<p class="sp-mono">mail.example.org</p>
|
||||||
|
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
|
||||||
|
<div class="sp-stats">
|
||||||
|
<div><b>1 284</b><span class="sp-muted">messages</span></div>
|
||||||
|
<div><b>96</b><span class="sp-muted">peak / h</span></div>
|
||||||
|
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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 method="post" action="status.html">
|
||||||
|
<h1 class="title is-3">Sign in</h1>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="username">Username</label>
|
||||||
|
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="password">Password</label>
|
||||||
|
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Overview · SelfPost</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.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="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-menu is-active">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<a class="navbar-item is-active" href="status.html">Overview</a>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="domain.html">Outbound</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="domain.html">Domains</a>
|
||||||
|
<a class="navbar-item" href="#">Log</a>
|
||||||
|
<a class="navbar-item" href="#">Queue</a>
|
||||||
|
<a class="navbar-item" href="#">DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">Inbound</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="#">Domains</a>
|
||||||
|
<a class="navbar-item" href="#">Log</a>
|
||||||
|
<a class="navbar-item" href="#">Queue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">Server</a>
|
||||||
|
<div class="navbar-dropdown">
|
||||||
|
<a class="navbar-item" href="#">System log</a>
|
||||||
|
<a class="navbar-item" href="#">Backup</a>
|
||||||
|
<a class="navbar-item" href="#">Users</a>
|
||||||
|
<a class="navbar-item" href="#">Settings</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link" href="#">admin</a>
|
||||||
|
<div class="navbar-dropdown is-right">
|
||||||
|
<a class="navbar-item" href="#">Account</a>
|
||||||
|
<a class="navbar-item" href="#">Help</a>
|
||||||
|
<hr class="navbar-divider">
|
||||||
|
<a class="navbar-item" href="login.html">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container sp-page">
|
||||||
|
<div class="level mb-5">
|
||||||
|
<div class="level-left">
|
||||||
|
<div>
|
||||||
|
<p class="sp-kicker">mail.example.org</p>
|
||||||
|
<h1 class="title is-3 mb-0">Overview</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box sp-health">
|
||||||
|
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
|
||||||
|
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
|
||||||
|
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
|
||||||
|
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
|
||||||
|
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
|
||||||
|
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-7">
|
||||||
|
<div class="box">
|
||||||
|
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
|
||||||
|
<table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
|
||||||
|
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
|
||||||
|
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-5">
|
||||||
|
<div class="box">
|
||||||
|
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
|
||||||
|
<table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
|
||||||
|
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
|
||||||
|
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
|
||||||
|
the side menu and input-with-addon fields. This file points Bulma's HSL
|
||||||
|
variables at the brand and adds the health strip and the record block. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bulma-family-primary: var(--sp-sans);
|
||||||
|
--bulma-family-secondary: var(--sp-sans);
|
||||||
|
--bulma-family-code: var(--sp-mono);
|
||||||
|
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
|
||||||
|
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
|
||||||
|
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
|
||||||
|
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
|
||||||
|
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
|
||||||
|
--bulma-radius: 6px; --bulma-radius-large: 10px;
|
||||||
|
--bulma-body-size: 15px;
|
||||||
|
/* Bulma derives link text and the on-primary colour from lightness steps;
|
||||||
|
at the brick's 33 % both land off-brand, so they are pinned. */
|
||||||
|
--bulma-link-text: var(--sp-brick);
|
||||||
|
--bulma-link-text-hover: var(--sp-brick-dark);
|
||||||
|
}
|
||||||
|
.button.is-primary, .button.is-primary:hover { color: #fff; }
|
||||||
|
a strong { color: inherit; }
|
||||||
|
html { background: var(--sp-paper); }
|
||||||
|
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
main { flex: 1; }
|
||||||
|
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
|
||||||
|
|
||||||
|
/* ---- navbar: brick, the stamp on it, hover dropdowns */
|
||||||
|
.navbar.is-primary { --bulma-navbar-height: 4rem; }
|
||||||
|
.navbar-brand img { max-height: 3rem; height: 3rem; }
|
||||||
|
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
|
||||||
|
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
|
||||||
|
.navbar.is-primary .navbar-start > .navbar-item.is-active,
|
||||||
|
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
|
||||||
|
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
|
||||||
|
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
|
||||||
|
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
|
||||||
|
|
||||||
|
/* ---- page */
|
||||||
|
.sp-page { padding-block: 2rem 2.5rem; }
|
||||||
|
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
|
||||||
|
.sp-kicker a { color: inherit; }
|
||||||
|
.title { color: var(--sp-ink); font-weight: 600; }
|
||||||
|
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
|
||||||
|
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
|
||||||
|
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
|
||||||
|
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
|
||||||
|
.table { background: transparent; }
|
||||||
|
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
||||||
|
.table td { vertical-align: middle; }
|
||||||
|
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
|
||||||
|
.sp-muted { color: var(--sp-muted); }
|
||||||
|
.sp-mono { font-family: var(--sp-mono); }
|
||||||
|
|
||||||
|
/* ---- health strip */
|
||||||
|
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
|
||||||
|
.sp-health > a {
|
||||||
|
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
|
||||||
|
box-shadow: inset 0 3px 0 var(--sp-ok);
|
||||||
|
}
|
||||||
|
.sp-health > a:last-child { border-right: 0; }
|
||||||
|
.sp-health > a:hover { background: var(--sp-paper); }
|
||||||
|
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
|
||||||
|
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
|
||||||
|
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
|
||||||
|
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
|
||||||
|
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
|
||||||
|
|
||||||
|
/* ---- domain page */
|
||||||
|
.sp-side { position: sticky; top: 1.5rem; }
|
||||||
|
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
|
||||||
|
.menu-list a { display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.menu-list a .tag { margin-left: auto; }
|
||||||
|
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
|
||||||
|
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
|
||||||
|
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
|
||||||
|
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
|
||||||
|
.sp-record .input[readonly], .sp-record .textarea[readonly] {
|
||||||
|
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
|
||||||
|
}
|
||||||
|
.sp-record .textarea { resize: none; min-height: 0; }
|
||||||
|
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
|
||||||
|
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
|
||||||
|
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
|
||||||
|
|
||||||
|
/* ---- sign-in */
|
||||||
|
.sp-signin { min-height: 100vh; margin: 0; }
|
||||||
|
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
|
||||||
|
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
|
||||||
|
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
|
||||||
|
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
|
||||||
|
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
|
||||||
|
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
|
||||||
|
.sp-foot a { color: inherit; text-decoration: underline; }
|
||||||
|
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
|
||||||
|
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 |
@@ -3,27 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Panel mockups</title>
|
<title>Panel redesign · three directions</title>
|
||||||
<link rel="stylesheet" href="shared/brand.css">
|
<link rel="stylesheet" href="shared/brand.css">
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<p class="kicker">SelfPost · panel mockups</p>
|
<p class="kicker">SelfPost · panel redesign · stage 2</p>
|
||||||
<h1>Every screen of the panel</h1>
|
<h1>The accepted direction, every screen</h1>
|
||||||
<p class="lead">The accepted design (<a href="../../plans/panel-redesign.md">the plan</a>): Bulma as the base, the
|
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
|
||||||
important things in view, details one or two clicks away, with a postal signature — the postmark verdict, numbered
|
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
|
||||||
mono section heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and Tabler icons.
|
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-three screens and a components page live in
|
||||||
The screens and a components page live in <a href="panel/overview.html">panel/</a>; a page of the running panel that
|
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
|
||||||
disagrees with its mockup is wrong. Serve this folder from the repository root: the pages load the panel's own fonts
|
fonts and mark by relative path.</p>
|
||||||
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>
|
<h2>Screen map</h2>
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<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>Signed out</th><td><a href="panel/login.html">Sign in</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>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> ·
|
<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-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
|
||||||
@@ -36,7 +34,7 @@
|
|||||||
<a href="panel/in-queue.html">Queue</a> (empty state)<br>
|
<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-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>
|
<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> ·
|
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> ·
|
||||||
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
|
<a href="panel/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>
|
<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>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
|
||||||
@@ -46,7 +44,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>What changed against the 1.x panel</h2>
|
<h2>What changed against today's panel</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
|
<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>
|
<em>Server › Health</em>, one click from each card.</li>
|
||||||
@@ -62,6 +60,79 @@
|
|||||||
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
|
<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>
|
so nothing has to be run to view them.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>Stage 1 · three directions on three key screens</h2>
|
||||||
|
<p class="lead">Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.</p>
|
||||||
|
|
||||||
|
<h2>Shared by all three</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Navigation by mail direction.</strong> Overview · <em>Outbound</em> (Domains, Log, Queue, DMARC reports) ·
|
||||||
|
<em>Inbound</em> (Domains, Log, Queue) · <em>Server</em> (System log, Backup, Users, Settings). Account, Help and
|
||||||
|
Sign out live with the user name. Eleven flat entries become four groups.</li>
|
||||||
|
<li><strong>One block per DNS record.</strong> Today "DNS status" and "records to publish" show every host twice.
|
||||||
|
Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.</li>
|
||||||
|
<li><strong>The domain page stops being nine cards.</strong> DNS and applications are the page; limits, report address,
|
||||||
|
export and delete are "rarely changed" and sit behind one click.</li>
|
||||||
|
<li><strong>Applications are a table</strong>; editing one is its own form page instead of a three-column panel inside a list row.</li>
|
||||||
|
<li>Brand carriers stay: the stamp, brick, IBM Plex. No inline <code>style</code> or script in any mockup.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>The directions</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<section>
|
||||||
|
<p class="kicker">A · Pico CSS 2.1</p>
|
||||||
|
<h3>Blank</h3>
|
||||||
|
<p>The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the
|
||||||
|
verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first
|
||||||
|
header row, the section's pages on the second.</p>
|
||||||
|
<p class="links"><a href="a/status.html">Overview</a><a href="a/domain.html">Domain</a><a href="a/login.html">Sign in</a></p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p class="kicker">B · Tabler 1.5 (Bootstrap 5)</p>
|
||||||
|
<h3>Console</h3>
|
||||||
|
<p>The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a
|
||||||
|
12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like
|
||||||
|
every other admin panel.</p>
|
||||||
|
<p class="links"><a href="b/status.html">Overview</a><a href="b/domain.html">Domain</a><a href="b/login.html">Sign in</a></p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p class="kicker">C · Bulma 1.0</p>
|
||||||
|
<h3>Counter</h3>
|
||||||
|
<p>Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over
|
||||||
|
the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the
|
||||||
|
boldest use of the brand colour.</p>
|
||||||
|
<p class="links"><a href="c/status.html">Overview</a><a href="c/domain.html">Domain</a><a href="c/login.html">Sign in</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What each one costs</h2>
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th></th><th>A · Pico</th><th>B · Tabler</th><th>C · Bulma</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Licence</th><td>MIT</td><td>MIT</td><td>MIT</td></tr>
|
||||||
|
<tr><th>Vendored CSS (minified)</th><td>83 KB</td><td>694 KB</td><td>678 KB</td></tr>
|
||||||
|
<tr><th>JavaScript</th><td>none</td><td>none for these screens; Bootstrap bundle only if dropdowns / modals are wanted</td><td>none</td></tr>
|
||||||
|
<tr><th>Build step</th><td>none</td><td>none</td><td>none (a Sass build could trim it; not needed)</td></tr>
|
||||||
|
<tr><th>CSP <code>default-src 'self'</code></th>
|
||||||
|
<td>needs <code>img-src 'self' data:</code> — 14 <code>data:</code> SVGs (select chevron, checkbox tick)</td>
|
||||||
|
<td>needs <code>img-src 'self' data:</code> — 28 <code>data:</code> SVGs</td>
|
||||||
|
<td>works as is — no <code>data:</code> URIs</td></tr>
|
||||||
|
<tr><th>Our own CSS in the mockup</th><td>≈ 200 lines</td><td>≈ 80 lines</td><td>≈ 100 lines</td></tr>
|
||||||
|
<tr><th>Markup style</th><td>semantic HTML, few classes — closest to today's templates</td><td>class-heavy Bootstrap markup</td><td>class-based, moderate</td></tr>
|
||||||
|
<tr><th>Own components</th><td>sheet, ledger row, postmark, record block</td><td>sidebar group label, record block</td><td>health strip, record block</td></tr>
|
||||||
|
<tr><th>Recognisable</th><td>most</td><td>least</td><td>middle</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Open points</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.</li>
|
||||||
|
<li><em>Inbound log</em> rides on the agreed <code>inbound-antispam-panel</code> journal; <em>Inbound queue</em> is a new filter over the one Postfix queue and needs its own roadmap row.</li>
|
||||||
|
<li>Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.</li>
|
||||||
|
<li>The earlier <a href="../panel-ui/index.html">panel-ui</a> experiment and <a href="../../plans/panel-redesign.md">panel-redesign.md</a> are superseded by whichever direction is accepted.</li>
|
||||||
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav class="navbar is-primary" aria-label="main navigation">
|
<nav class="navbar is-primary" aria-label="main navigation">
|
||||||
<div class="container">
|
<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-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||||
<div class="navbar-menu">
|
<div class="navbar-menu is-active">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<a class="navbar-item" href="overview.html">Overview</a>
|
<a class="navbar-item" href="overview.html">Overview</a>
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
<a class="navbar-link" href="health.html">Server</a>
|
<a class="navbar-link" href="health.html">Server</a>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||||
|
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
<a class="navbar-item" href="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="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="users.html"><i class="ti ti-users"></i>Users</a>
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
<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>
|
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||||
<hr class="navbar-divider">
|
<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>
|
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,34 +69,24 @@
|
|||||||
<div class="sp-grow">
|
<div class="sp-grow">
|
||||||
<p class="sp-kicker">Signed in as admin · global</p>
|
<p class="sp-kicker">Signed in as admin · global</p>
|
||||||
<h1 class="title is-3">Account</h1>
|
<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>
|
<p class="sp-lead">Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-6">
|
<div class="column is-5">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
|
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<form class="sp-form" method="post" action="#">
|
<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="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>
|
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||||
<p class="help">Your own address. 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>
|
<p class="help">Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</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>
|
<button type="submit" class="button is-primary">Save profile</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
|
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
@@ -111,9 +102,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-7">
|
||||||
|
<div class="box">
|
||||||
|
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><div class="sp-end">Sent to mix@example.org</div></div>
|
||||||
|
<div class="sp-box-body">
|
||||||
|
<form class="sp-form" method="post" action="#">
|
||||||
|
<p class="sp-kicker mb-2">Your domains</p>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A DNS record stops matching — DKIM, SPF, DMARC or an inbound MX</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A domain or an application reaches its rate limit and mail is being refused</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> An inbound domain's upstream cannot be reached and mail is piling up</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> DMARC reports start showing failures for a domain</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Mail is held in quarantine</label></div>
|
||||||
|
<p class="sp-kicker mt-4 mb-2">The server · global role</p>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> The TLS certificate is within 14 days of expiry</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A process or a milter socket stops answering</label></div>
|
||||||
|
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Messages sit in a queue for more than a day</label></div>
|
||||||
|
<p class="help mb-3">One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.</p>
|
||||||
|
<button type="submit" class="button is-primary">Save notifications</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" id="dmarc">
|
||||||
|
<div class="sp-box-head"><span class="sp-no">04</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</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-record">
|
||||||
|
<form class="sp-form" method="post" action="#">
|
||||||
|
<div class="field"><label class="label" for="rua">My default report address</label>
|
||||||
|
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
|
||||||
|
<option>SelfPost hosted — read them under DMARC reports</option>
|
||||||
|
<option selected>My account e-mail — mix@example.org</option>
|
||||||
|
<option>Another address…</option>
|
||||||
|
<option>No reports</option>
|
||||||
|
</select></div></div>
|
||||||
|
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
|
||||||
|
<button type="submit" class="button is-primary">Save report address</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="sp-record">
|
||||||
|
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</small></div>
|
||||||
|
<div class="sp-row">
|
||||||
|
<div class="field"><label class="label">Host / name</label>
|
||||||
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||||
|
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label class="label">Value to publish</label>
|
||||||
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||||
|
</div>
|
||||||
|
</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>
|
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav class="navbar is-primary" aria-label="main navigation">
|
<nav class="navbar is-primary" aria-label="main navigation">
|
||||||
<div class="container">
|
<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-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
|
||||||
<div class="navbar-menu">
|
<div class="navbar-menu is-active">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<a class="navbar-item" href="overview.html">Overview</a>
|
<a class="navbar-item" href="overview.html">Overview</a>
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
<a class="navbar-link" href="health.html">Server</a>
|
<a class="navbar-link" href="health.html">Server</a>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
|
||||||
|
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
|
||||||
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
|
<a class="navbar-item" href="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 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="users.html"><i class="ti ti-users"></i>Users</a>
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
|
<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>
|
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
|
||||||
<hr class="navbar-divider">
|
<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>
|
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sp-perf" aria-hidden="true"></div>
|
<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>
|
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||||
<main>
|
<main>
|
||||||
<div class="container sp-page">
|
<div class="container sp-page">
|
||||||
<div class="sp-head">
|
<div class="sp-head">
|
||||||
@@ -75,9 +76,9 @@
|
|||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
<div class="box">
|
<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-head"><span class="sp-no">01</span><h2>Full backup</h2></div>
|
||||||
<div class="sp-box-body">
|
<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>
|
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. 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="#">
|
<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="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="sp-pair">
|
||||||
@@ -85,8 +86,6 @@
|
|||||||
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></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>
|
</div>
|
||||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
|
<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>
|
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +94,9 @@
|
|||||||
|
|
||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
<div class="box" id="import">
|
<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-head"><span class="sp-no">02</span><h2>Import a domain</h2></div>
|
||||||
<div class="sp-box-body">
|
<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>
|
<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#export">domain's settings</a>.</p>
|
||||||
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
<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="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="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>
|
||||||
@@ -110,7 +109,7 @@
|
|||||||
</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>
|
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ NAV = [
|
|||||||
]),
|
]),
|
||||||
("server", "Server", "health.html", "ti-server-2", [
|
("server", "Server", "health.html", "ti-server-2", [
|
||||||
("health", "Health", "health.html", "ti-heartbeat"),
|
("health", "Health", "health.html", "ti-heartbeat"),
|
||||||
|
("preflight", "Preflight", "preflight.html", "ti-checklist"),
|
||||||
("system-log", "System log", "system-log.html", "ti-file-text"),
|
("system-log", "System log", "system-log.html", "ti-file-text"),
|
||||||
("backup", "Backup", "backup.html", "ti-archive"),
|
("backup", "Backup", "backup.html", "ti-archive"),
|
||||||
("users", "Users", "users.html", "ti-users"),
|
("users", "Users", "users.html", "ti-users"),
|
||||||
@@ -51,39 +52,15 @@ HEAD = """<!DOCTYPE html>
|
|||||||
</head>
|
</head>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
|
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
|
||||||
' · <a href="#">Source</a> · No warranty</footer>')
|
' · <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):
|
def navbar(section, page):
|
||||||
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
|
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
|
||||||
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
|
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
|
||||||
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a>' + touchnav(section, page) + '</div>',
|
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
|
||||||
' <div class="navbar-menu">', ' <div class="navbar-start">']
|
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
|
||||||
for key, label, href, _icon, pages in NAV:
|
for key, label, href, _icon, pages in NAV:
|
||||||
cur = " sp-current" if key == section else ""
|
cur = " sp-current" if key == section else ""
|
||||||
if not pages:
|
if not pages:
|
||||||
@@ -104,9 +81,7 @@ def navbar(section, page):
|
|||||||
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
|
' <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>',
|
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
|
||||||
' <hr class="navbar-divider">',
|
' <hr class="navbar-divider">',
|
||||||
# Signing out changes state, so it is a POST: a form with a button, not a link.
|
' <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>',
|
||||||
' <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>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
|
||||||
'<div class="sp-perf" aria-hidden="true"></div>']
|
'<div class="sp-perf" aria-hidden="true"></div>']
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ box button buttons checkbox column columns control field file file-cta file-inpu
|
|||||||
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-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
|
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-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
|
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
|
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
|
||||||
""".split())
|
""".split())
|
||||||
# Classes the shell (build.py, later layout.html) owns; screens never write them.
|
# Classes the shell (build.py, later layout.html) owns; screens never write them.
|
||||||
@@ -37,7 +37,7 @@ 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
|
# 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.
|
# 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",
|
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")
|
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
|
||||||
|
|
||||||
|
|
||||||
class Node:
|
class Node:
|
||||||
|
|||||||