Compare commits
137 Commits
main
...
4a54af15cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a54af15cc | |||
| 6860f4523e | |||
| 1d726a90db | |||
| 14322890e8 | |||
| 9b3ce0b33e | |||
| 52b3b3ea3e | |||
| bd61fd9c23 | |||
| e0b754bafe | |||
| 284bd22dfb | |||
| 5a0724778a | |||
| 9d6d89c79d | |||
| c839f68c57 | |||
| 998b05821b | |||
| 1382f8b5f4 | |||
| c92f4bc616 | |||
| 0025b5d85d | |||
| 7c27dc3b89 | |||
| c304c92955 | |||
| 2f01edff19 | |||
| 99a0483226 | |||
| 928d065d13 | |||
| 57d09ce1e2 | |||
| 94ab207752 | |||
| bea482e30d | |||
| d2a151e752 | |||
| 82fc821d1a | |||
| d2dffc2e9a | |||
| c70c115224 | |||
| 795e945888 | |||
| ad991dea4b | |||
| a8e28e1921 | |||
| db56fcc3f3 | |||
| 2c5c21f212 | |||
| 5d3000a48f | |||
| 251344f880 | |||
| 3b0219f0eb | |||
| 18d90b2dfc | |||
| d49351c022 | |||
| ed0a786739 | |||
| 4b0f537508 | |||
| 6d2d49257d | |||
| 68f83139ee | |||
| bc68bfd4c7 | |||
| 429f9ad232 | |||
| 2595b0b673 | |||
| 522425a4da | |||
| 22f86d120a | |||
| dbaed05f9c | |||
| 995bd5db84 | |||
| e335526162 | |||
| b041e279b0 | |||
| a63e5c9b23 | |||
| 47d32ae014 | |||
| f6c12765a6 | |||
| 4b25159586 | |||
| 09abd989a1 | |||
| 322ee6c71d | |||
| f26e9b3cc5 | |||
| 9b4a368b05 | |||
| 8add005822 | |||
| d9384bbb4d | |||
| 02e095601f | |||
| 6063fe706e | |||
| 249d9a6488 | |||
| 987b7ea770 | |||
| 5350fe37b4 | |||
| 5041f87e57 | |||
| 0ca5cba453 | |||
| 12a11f185c | |||
| a06faac213 | |||
| 89447074c5 | |||
| ce34f02dac | |||
| a55dbaeec0 | |||
| 66fab00807 | |||
| 61d2ec25e4 | |||
| 9871dce15f | |||
| a96b902df0 | |||
| 9cac9450d6 | |||
| dab7fc1609 | |||
| ae3d3ce041 | |||
| c8b067a686 | |||
| 3b0a71cf11 | |||
| 8c95192a7a | |||
| 82ec287ba1 | |||
| 750a65d5ee | |||
| a1160e0fb8 | |||
| d18e70b67e | |||
| 2f30928267 | |||
| d653ec364b | |||
| c3579db99b | |||
| 9d3b8e6351 | |||
| a7b23f7a63 | |||
| c8abec376a | |||
| c2edc586ef | |||
| 4fe071db79 | |||
| 9333e2657c | |||
| f9026c49cb | |||
| 03b6a2a2f9 | |||
| c7c0c171f8 | |||
| 3531a6694c | |||
| 69d54b45d9 | |||
| ac5b37d1e2 | |||
| 147072dbb9 | |||
| 1f29bef87a | |||
| 4ae167b900 | |||
| 1d40390488 | |||
| 0dd6a460b4 | |||
| 74d493cd12 | |||
| f2fc07fafa | |||
| 734f5fa68c | |||
| 2148496dea | |||
| 047f7433ba | |||
| 168264fb23 | |||
| 86783d7b44 | |||
| 1b21f001eb | |||
| 88f9d33e8d | |||
| 158b5323d3 | |||
| bd64e80885 | |||
| 58b458c264 | |||
| ae42450ec1 | |||
| 223f3cdc42 | |||
| 9d4942aef6 | |||
| 901fc0e05a | |||
| 36ecf75632 | |||
| dc08ccbf7c | |||
| 2dbd8d009e | |||
| ec4d4b9faa | |||
| b2692e4bb7 | |||
| ff4d6a6d28 | |||
| bf477f4e6a | |||
| f62c714e2b | |||
| 2b08a947b2 | |||
| 39b74e204e | |||
| ed9e942e42 | |||
| 248a4db7a8 | |||
| 4e589e1080 | |||
| 87388b4209 |
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Agent rules for the SelfPost repository (formerly spec §12)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent rules
|
||||
|
||||
1. **No git commits** without an explicit instruction in the prompt.
|
||||
2. After Go changes: `go build`, `go vet`; fix all issues. Run `go test` when
|
||||
tests exist.
|
||||
3. Before calling a container task done: the image builds and the container starts.
|
||||
4. Iterate: minimal skeleton first, then features.
|
||||
5. Security requirements — `docs/security.md`; implement with the feature, not deferred.
|
||||
6. Do not implement out-of-scope items (`docs/product.md`); do not change fixed
|
||||
assumptions without agreement.
|
||||
7. For large tasks: propose a plan before coding unless the user already approved one.
|
||||
8. Check licence compatibility of new Go dependencies (permissive or GPL-family for
|
||||
AGPL-3.0).
|
||||
|
||||
Model routing — `docs/development.md` § «Model routing». Pre-release security
|
||||
**review** (not authorship) — Fable.
|
||||
|
||||
Commit protocol, CHANGELOG, and phase closure — `docs/development.md` §
|
||||
«Commits and release build» and «Phase closure».
|
||||
@@ -0,0 +1,11 @@
|
||||
# Keep the build context minimal and free of dev-only material.
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
dev
|
||||
docs
|
||||
bin
|
||||
*.exe
|
||||
README.md
|
||||
LICENSE
|
||||
Makefile
|
||||
@@ -0,0 +1,32 @@
|
||||
# Normalize all text files to LF in the working tree.
|
||||
# Critical: shell scripts, Dockerfile and configs run inside a Linux container
|
||||
# and MUST NOT get CRLF line endings (a "#!/bin/sh\r" shebang breaks startup).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit LF for files copied into / executed in the image.
|
||||
*.go text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.cf text eol=lf
|
||||
*.cnf text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.js text eol=lf
|
||||
*.html text eol=lf
|
||||
*.tmpl text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
Makefile text eol=lf
|
||||
go.mod text eol=lf
|
||||
go.sum text eol=lf
|
||||
LICENSE text eol=lf
|
||||
|
||||
# Binary assets — never touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,154 @@
|
||||
name: release
|
||||
|
||||
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
||||
# Ordinary commits do not publish anything — only a pushed tag matching
|
||||
# vX.Y.Z does. That tag is the single source the version comes from: it goes
|
||||
# into both the image tag and the panel binary's -ldflags version, so the two
|
||||
# can never drift apart (the invariant restore's version check in spec 7.5.A
|
||||
# depends on).
|
||||
#
|
||||
# Native per-architecture builds (see docs/development.md), not qemu:
|
||||
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
||||
# tag on its own native runner; a merge job then combines them into the one
|
||||
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
||||
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
||||
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering
|
||||
# in the registry as a side effect (harmless — the version tag's immutability,
|
||||
# spec 10.1, is about that tag, not these).
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Derive version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (native, loaded locally for the e2e gate)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: build/Dockerfile
|
||||
load: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
tags: selfpost:e2e
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: test/e2e/go.sum
|
||||
|
||||
- name: e2e (gates publishing — see docs/development.md)
|
||||
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push per-arch tag
|
||||
# GHCR sometimes rejects the final manifest with "unknown blob" after
|
||||
# every layer reports Pushed (registry race / overwrite of the same
|
||||
# version tag). Retries are idempotent for identical local bytes.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}"
|
||||
docker tag selfpost:e2e "$image"
|
||||
attempt=1
|
||||
max=4
|
||||
backoff=15
|
||||
while true; do
|
||||
set +e
|
||||
out=$(docker push "$image" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf '%s\n' "$out"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$out" >&2
|
||||
if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then
|
||||
echo "::error::docker push failed for ${image} (attempt ${attempt}/${max}, exit ${rc})" >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "::warning::transient GHCR error pushing ${image} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2
|
||||
sleep "$backoff"
|
||||
attempt=$((attempt + 1))
|
||||
backoff=$((backoff * 2))
|
||||
done
|
||||
|
||||
merge:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Merge per-arch tags into the version manifest
|
||||
# Same GHCR "unknown blob" class of failure can hit imagetools create
|
||||
# when assembling the multi-arch version tag.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_tag="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}"
|
||||
amd64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64"
|
||||
arm64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64"
|
||||
attempt=1
|
||||
max=4
|
||||
backoff=15
|
||||
while true; do
|
||||
set +e
|
||||
out=$(docker buildx imagetools create -t "$version_tag" "$amd64" "$arm64" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf '%s\n' "$out"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$out" >&2
|
||||
if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then
|
||||
echo "::error::imagetools create failed for ${version_tag} (attempt ${attempt}/${max}, exit ${rc})" >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "::warning::transient GHCR error merging ${version_tag} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2
|
||||
sleep "$backoff"
|
||||
attempt=$((attempt + 1))
|
||||
backoff=$((backoff * 2))
|
||||
done
|
||||
@@ -0,0 +1,32 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
|
||||
- name: Format
|
||||
run: |
|
||||
fmt="$(gofmt -l .)"
|
||||
if [ -n "$fmt" ]; then
|
||||
echo "$fmt"
|
||||
echo "gofmt needed on the files above; run 'gofmt -w .'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
+12
@@ -1 +1,13 @@
|
||||
# Development-only material (secrets, server access notes) — never published.
|
||||
dev/
|
||||
|
||||
# Build artifacts (not committed).
|
||||
/bin/
|
||||
*.exe
|
||||
|
||||
# Local Go caches / test output, if ever present.
|
||||
*.out
|
||||
|
||||
# e2e scratch stand (generated cert, DKIM zone, sink dump, /data bind mount)
|
||||
# — recreated fresh by every `make e2e` run, never checked in.
|
||||
/test/e2e/.stage/
|
||||
|
||||
+718
@@ -0,0 +1,718 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||
heading, and browser title. The route stays `/account`, so existing links
|
||||
and bookmarks are unaffected.
|
||||
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
|
||||
the current account rather than as a stray word above the Settings link.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||
`unknown blob` (and similar) errors after layers already uploaded.
|
||||
|
||||
## [1.0.0] - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
|
||||
`mail.log` lines Postfix wrote about that message, oldest first — the
|
||||
connection to the receiving server, its reply, and the status that reply was
|
||||
filed as. It is a table of two columns, when and what, so the seconds between
|
||||
the connection and the reply line up down one edge; the timestamp is the
|
||||
log's own wall clock without its microseconds and offset, and a line whose
|
||||
head is not a timestamp keeps its whole text under *Message*
|
||||
(`logtail.SplitTimestamp`, which reads both postlogd's format and syslog's).
|
||||
The queue id was printed on this page as something to go and search
|
||||
the system log for by hand; the search is done for the operator instead
|
||||
(`logtail.QueueLines`). The read is a bounded tail of the current log and
|
||||
matches only lines carrying this message's queue id, anchored so a shorter id
|
||||
is not found inside a longer one. Send-log rows outlive `mail.log` — retention
|
||||
is ninety days, rotation keeps fourteen files — so a message with no lines
|
||||
left says so rather than reporting a failure.
|
||||
- A **History** block on the same page: the journal's two timestamps stated as
|
||||
the steps they stand for — accepted and queued, then delivered, deferred,
|
||||
bounced, or refused before queueing — each with the status it reached in the
|
||||
panel's own ok/warn/error/unknown badge vocabulary. A message still queued
|
||||
shows the delivery report it is waiting for as a step that has not happened.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
|
||||
into `/etc/postfix/tls-internal` as `root:root` before `postconf` /
|
||||
`postfix check`. Bind-mounted keys owned by the CI/host UID made
|
||||
`postfix check` fail and the container exit before supervisord started.
|
||||
- Postfix config: allow `maillog_file` under `/data` via
|
||||
`maillog_file_prefixes=/var,/dev/stdout,/data`, and pin the `postlog`
|
||||
master.cf service. After mail.log moved to `/data/log`, `postfix check`
|
||||
fatally rejected the path (default prefixes are only `/var` and
|
||||
`/dev/stdout`) and often left stderr/mail.log empty.
|
||||
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
|
||||
panel-owned; host `ReadFile` got permission denied on CI). Reclaim `/data`
|
||||
ownership before TempDir/stage cleanup so panel/postfix UIDs do not fail
|
||||
Go's `RemoveAll`.
|
||||
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
|
||||
fails with the FATAL text, not an earlier `set -e` abort), and `chmod 755
|
||||
/data` after chown so OpenDKIM/Postfix can traverse bind mounts that arrive
|
||||
as mode `0700` (Go `TempDir`, some host umasks) — otherwise KeyTable is
|
||||
unreachable and the container crash-loops. E2e stand uses `restart: "no"` and
|
||||
surfaces selfpost logs from the supervisor readiness check.
|
||||
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
||||
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
||||
and mask the real error (release CI on both amd64 and arm64).
|
||||
- A send-log row could stay `queued` forever after the container was recreated.
|
||||
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
||||
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||
resolve a queued row now outlive the container the same way the journal does.
|
||||
`postlogd` writes the file as user `postfix` and the unprivileged panel reads
|
||||
it through the shared `selfpost` group (directory `2750`, file `0640`,
|
||||
re-normalised on every start); logrotate creates each new file the same way.
|
||||
Existing deployments need no action beyond the upgrade — the directory is
|
||||
created on first start — but the log written by the previous image is gone
|
||||
with its container, and the tailer starts the new file from its end.
|
||||
- A row whose delivery lines are gone for good is no longer left `queued`
|
||||
indefinitely: every five minutes the tailer compares rows still queued from
|
||||
more than two minutes ago against `postqueue -p`, and marks `bounced` those
|
||||
whose message Postfix no longer holds — it will never report on them again.
|
||||
The sweep waits until the tailer has read the log to its end (on a restart the
|
||||
log itself holds the answer) and does nothing at all if the queue cannot be
|
||||
listed, so a message merely in flight, or a `postqueue` that fails, never
|
||||
closes a row.
|
||||
|
||||
### Changed
|
||||
|
||||
- Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
|
||||
export) in the operator guide, security notes, architecture, and the Backup /
|
||||
Export panel copy.
|
||||
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
|
||||
guide; local trial rewrites the printed `https://<hostname>/…` link to
|
||||
`http://127.0.0.1:8080/…`); domain import uses the file extension / magic
|
||||
bytes for the password field, not an "encrypted" checkbox; architecture
|
||||
layering and route table match `web`→`store` and `POST /domains/import`;
|
||||
OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived
|
||||
"spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes,
|
||||
and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts from
|
||||
before the published `1.0.0` image are called out in the guide and
|
||||
`development.md`. Roadmap points at CHANGELOG `[0.5.0]` Security and
|
||||
refreshed `internal/web` size / symbol links.
|
||||
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
|
||||
fourteen rotated copies — diagnostic output rather than state to restore, and
|
||||
otherwise by far the largest thing in the archive.
|
||||
- Monitoring screens (status, mail queue, system log, deliveries) use adaptive
|
||||
HTMX polling: 5 s while the operator is active on the page, 30 s when the tab
|
||||
is visible but idle, and no requests while the tab is hidden. Scheduling
|
||||
lives in `panel.js` (`data-poll` markers) instead of `hx-trigger="every …"`,
|
||||
which would need `unsafe-eval` under the panel's CSP.
|
||||
- Documentation package consolidated into `docs/development.md`: Documentation
|
||||
map, user-facing deliverables, maintenance rules, and code-to-prose
|
||||
verification table (from closed `documentation-plan.md`); resuming work,
|
||||
model routing, commits, and phase closure (from closed `progress.md`).
|
||||
`docs/archive/` removed — history is git + CHANGELOG. README Documentation
|
||||
index lists operator docs plus the internal roadmap. Agent rules point at
|
||||
`development.md`.
|
||||
- The delivery page is laid out in two columns: what the journal recorded on
|
||||
the left, what happened to the message on the right, and the delivery log at
|
||||
full width under both. The facts the page used to stack one per line — domain,
|
||||
application, queue id, journal id and the two timestamps — are a grid of tiles
|
||||
instead, since a page of mostly empty rows was what the full-width stack came
|
||||
to for six short values. The subject heads the page and the sender, recipient
|
||||
and outcome are the line under it, so what the message was and how it ended
|
||||
are both on the first line. The page takes the whole column rather than the
|
||||
reading measure, as the other three monitoring pages already did.
|
||||
- `docs/development.md` restructured into stack, dependencies, build, release,
|
||||
testing, and CI; agent rules moved to `.cursor/rules/agent-rules.mdc`;
|
||||
dev-host-specific workflow and `mixfed.ru` references removed from docs.
|
||||
- `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to
|
||||
English; `roadmap.md` remains Russian (internal tracker).
|
||||
- Deploy pin and local-trial image tag set to `ghcr.io/mixeme/selfpost:1.0.0`
|
||||
(compose and git tag `v1.0.0` cut together). Retired
|
||||
`docs/implementation-plan.md` and `docs/v1.x-closure-plan.md`; Makefile,
|
||||
`release.yml`, and e2e comments point at `docs/development.md`. Roadmap
|
||||
v1.x documentation/deploy tail closed.
|
||||
|
||||
## [0.6.0] - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
|
||||
every send-log row. It carries what the log itself no longer shows — the
|
||||
sending domain, the application the message was submitted under, the Postfix
|
||||
queue id to search the system log for, when the status was last reported —
|
||||
so the journal can grow fields without the table having to find columns for
|
||||
them. *Back* returns to the page and filters the row was opened from, rebuilt
|
||||
from the log's own parameters only.
|
||||
|
||||
- A **DNS** badge in the domain list, one per row, carrying the same
|
||||
ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that
|
||||
domain's DKIM, SPF and DMARC checks, so a domain whose records were never
|
||||
published is visible without opening it. The badge links to that domain's
|
||||
DNS status card. The checks run concurrently across the listed domains —
|
||||
each carries its own timeout, and in series a dead resolver would multiply
|
||||
that wait by the number of domains — and share the checker's cache with the
|
||||
domain page, so a repeat view costs no lookups. A domain whose DKIM key
|
||||
cannot be read stays "unknown" rather than being reported as misconfigured,
|
||||
since the missing half is this server's.
|
||||
- Machine metrics on the status page: a **Machine** card reporting the
|
||||
processor (busy percentage, core count, load average), memory and swap, and
|
||||
network throughput and totals per interface, read from the kernel's counters
|
||||
in `/proc` (`internal/health/machine.go`). CPU and throughput are differences
|
||||
between two readings, so they are measured against the previous poll of the
|
||||
status fragment and reported as still being measured until a second reading
|
||||
exists — a page opened after a long idle stretch re-baselines rather than
|
||||
presenting that stretch as the current load. A fully busy processor (≥90%)
|
||||
warns and an exhausted machine (≥97% of memory in use) errors, both counting
|
||||
towards the page's headline verdict, since either delays or kills the mail
|
||||
path; throughput is reported and never graded. Counters that cannot be read
|
||||
— no `/proc` outside Linux — leave the card in place showing "unknown". The
|
||||
usage bars are `<meter>` elements: the panel's CSP has no inline-style
|
||||
exemption, so a bar's length has to travel on an attribute.
|
||||
- An index of the current page's own sections in the navigation column, for the
|
||||
two pages long enough to need one: the domain page (nine cards, from the DNS
|
||||
records to publish down to the danger zone) and the status page (eight). Each
|
||||
card carries an id and the page's template defines the list
|
||||
(`{{define "sections"}}`); every other page defines nothing and shows no
|
||||
index. `panel.js` marks the section currently in view, looking its targets up
|
||||
by id on each pass so the status page swapping its cards out every five
|
||||
seconds cannot leave it measuring boxes that have left the document. The
|
||||
links are plain fragment links and work with JavaScript blocked; only the
|
||||
highlight needs it.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every panel page is laid out in one column of the same width, so moving
|
||||
between them no longer shifts the navigation and the cards sideways. The
|
||||
column used to be the 48rem reading measure, which the send log, the mail
|
||||
queue and the system log widened to 64rem for their tables — and since the
|
||||
navigation and the page are centred as a pair, that difference moved
|
||||
everything on screen on the way between two pages. The column is now 64rem
|
||||
throughout, with the reading measure kept inside it: a page's heading, cards,
|
||||
back link and version footer are held to 48rem and centred in the column,
|
||||
and the three pages made of data opt out and take the column whole. Which
|
||||
pages those are is declared by the page itself (a `wide` block in its
|
||||
template, the same mechanism as the section index) rather than derived from
|
||||
the navigation entry, so a single delivery's page — prose, but filed under
|
||||
the send log — keeps the measure. The scrollbar's width is now reserved on
|
||||
every page as well: without it a short page and a long one were laid out in
|
||||
viewports differing by that width, which moved the same things again.
|
||||
|
||||
- The mark's small-size variant — the tab icon and the `SP` initials it
|
||||
carries — sets its S in Medium where the wordmark sets it in ExtraLight.
|
||||
Against the P's SemiBold the ExtraLight S is a 0.90 stem against 3.40,
|
||||
which at 16px is a quarter of a pixel against most of one, so the pair
|
||||
rasterised to a P with a smudge beside it. Medium gives up the
|
||||
Self/Post weight play, which needs more pixels than this variant exists
|
||||
to work in, in exchange for both letters being there. The variants big
|
||||
enough to carry the contrast keep it. `favicon.png` is regenerated to
|
||||
match, and the outlines are IBM Plex Sans as before — the same
|
||||
font-size, letter-spacing and baseline, with only the S's weight moved.
|
||||
- The stamp's `SELF-HOSTED SMTP RELAY` line is set at 11.5/0.15 instead of
|
||||
7.2/2.8, and no longer carries `opacity=".78"`. At the old size its stems
|
||||
rasterised to about half a device pixel, so more than half its ink landed
|
||||
as antialiasing — the typical pixel reached 2.1:1 against the brown rather
|
||||
than the 7.3:1 the two colours are worth, and none reached full strength.
|
||||
The line keeps its footprint and its monospaced cells: the width the
|
||||
tracking was spending went to the glyphs, whose cap height rises from 5.2
|
||||
to 8.3. The mark is used at 330px on the login and setup pages, which is
|
||||
where this was worst. `internal/web/static/logo.svg` is a copy of
|
||||
`docs/assets/selfpost-stamp.svg` and both carry the change, as does the
|
||||
proof sheet the outlines are drawn from.
|
||||
- The delivery log lists what identifies a message and nothing else: time,
|
||||
sender, recipient, subject, status. Domain and application, which were a
|
||||
column each, remain the log's two filters and now appear per message on the
|
||||
delivery page. The two dropped columns were the widest thing in the table
|
||||
after the addresses, and both repeat down the page whenever a filter is set.
|
||||
- Subjects are decoded for display as well as on the way in, so the rows the
|
||||
journal-milter recorded before it decoded them itself — the ones an operator
|
||||
is most likely to still be reading — show their text rather than
|
||||
`=?utf-8?Q?…?=`. The decoder moved to `internal/mailhdr` and is shared by the
|
||||
milter and the panel; it is idempotent, so a row decoded once passes through
|
||||
unchanged.
|
||||
- The panel's navigation is a column down the left edge instead of a bar across
|
||||
the top. As a bar it did not fit on one row — six page entries and the
|
||||
session block against the panel's width — and had to be split into two,
|
||||
costing the top of every page; standing it up removes the compromise, gives
|
||||
the entries one left edge to scan down, and leaves room under them for the
|
||||
section index above. It is sticky, so both lists stay in view on the long
|
||||
pages, and the current entry is marked down its leading edge rather than
|
||||
underlined. Below the width the two columns need, it lies back down into the
|
||||
wrapping rows it used to be — no drawer and no hamburger, since six entries
|
||||
fit. The markup now lists the blocks in the order they are drawn, so the tab
|
||||
order follows the eye instead of starting at Sign out.
|
||||
- An application's mode and rate-limit fields open under its row of controls
|
||||
instead of inside it. Both panels were `<details>`, so each opened where its
|
||||
own toggle sat and cut the row of four in half, pushing New password and
|
||||
Delete below a block of fields — the buttons moved every time a panel was
|
||||
opened or closed. The toggle is now a hidden checkbox with its label drawn as
|
||||
the button, and the panel is the last child of the row, so the four controls
|
||||
keep their places and what a panel reveals is laid out beneath all of them.
|
||||
It stays keyboard-reachable and, being pure CSS, still works with JavaScript
|
||||
blocked, as the disclosure did. Inside a panel the submit buttons take the
|
||||
ordinary form spacing back from the compact row style that was leaving them
|
||||
flush against the field above, and Save limit and Remove limit — two posts,
|
||||
hence two forms — share one row, the first button bound to its form by the
|
||||
`form` attribute rather than by sitting inside it.
|
||||
- The mark at the head of the navigation column takes the column's full width
|
||||
instead of the 110px it kept from the bar. In a row that size was all there
|
||||
was room for; in a column it left the mark ending halfway across, with no
|
||||
edge shared with anything below it. At the column's width its edges line up
|
||||
with the page entries under it, as the full mark already does with the card
|
||||
beneath it on the signed-out pages. Where the column lies back down into a
|
||||
bar it returns to the compact size, which is what fits beside the entries.
|
||||
- The import card reads the file's extension instead of asking whether the file
|
||||
is encrypted. The checkbox was a question the server never consulted — it
|
||||
decides from the envelope's magic bytes — so the answer could only be wrong.
|
||||
Choosing a `.spde` file reveals the password field and a `.json` file hides
|
||||
it; an unrecognised extension reveals it, and with no file chosen the field
|
||||
stays hidden, since there is nothing yet for a password to open. With
|
||||
JavaScript blocked the field is shown, so an encrypted import still works.
|
||||
|
||||
## [0.5.0] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- A bounce could be recorded as a successful delivery. The log-tailer's
|
||||
delivery-line pattern matched `status=` greedily, so it took the *last*
|
||||
occurrence on the line — and Postfix appends the remote server's reply
|
||||
verbatim, which the far end controls. A rejection whose reply text contained
|
||||
`status=sent` was filed as `sent` in the send log. The pattern now takes the
|
||||
first `status=` after the recipient, which is the real field
|
||||
(`internal/logtail/logtail.go`); found while extending `TestParseDelivery`.
|
||||
- Log-tailer resumes where it stopped instead of jumping to end-of-file on
|
||||
every start (phase 3, `docs/code-review.md`): the read position and a
|
||||
fingerprint of the log's head are persisted (`logtail_state`, migration
|
||||
`0003`), so delivery lines written while the panel was down are parsed and
|
||||
their send-log rows no longer stay `queued` forever. A log that changed
|
||||
identity while the panel was down is read from the start; a first-ever start,
|
||||
with nothing stored, still begins at the end. Container recreate remains a
|
||||
gap — `mail.log` is not in `/data` (`docs/security.md`).
|
||||
- Level-2 rate limit no longer overshoots under concurrency: messages that
|
||||
passed the check at MAIL FROM but have not reached the send log yet are
|
||||
counted alongside the stored rows (`internal/milter/inflight.go`), so
|
||||
parallel SMTP sessions cannot each spend the same last slot. Slots are
|
||||
released at end-of-message, on ABORT, and after a 10-minute TTL, so a client
|
||||
that drops mid-transaction cannot hold one — the limiter stays fail-open.
|
||||
|
||||
### Changed
|
||||
|
||||
- The project has a single public home: `github.com/mixeme/selfpost`. Codeberg
|
||||
is being retired, so the Go module path moved with it — `go.mod`,
|
||||
`test/e2e/go.mod`, every import, the `Makefile` `MODULE` variable and the
|
||||
`-ldflags` version stamp in `build/Dockerfile` and `docs/development.md`. An
|
||||
import path pointing at a host that is going away would break `go get` and
|
||||
`go install` outright, which is why this is not only a documentation change.
|
||||
README no longer lists a primary/mirror pair.
|
||||
- Code comments no longer cite the archived specification. References like
|
||||
"spec 7.6.1" or "spec 5.1" pointed into `docs/archive/specification-v1.0.md`,
|
||||
which is explicitly not a source of truth; each is now a reference to the
|
||||
live document that owns the subject — `docs/architecture.md` (with section),
|
||||
`docs/product.md`, `docs/security.md`, or the README. Comments only; no
|
||||
behaviour is affected.
|
||||
- `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the
|
||||
release-commit steps), 1, 1.5, 2 and 3 are all closed — and the rest of the
|
||||
document had become a second copy of what `architecture.md`, `security.md`
|
||||
and the code comments already say. What was genuinely open moved to
|
||||
`docs/roadmap.md`: splitting `internal/web` into subpackages, a consolidated
|
||||
documentation index in the README, the adaptive polling interval for an idle
|
||||
but visible tab, and `CONTRIBUTING.md`. The review text stays in git history
|
||||
(`522425a`); the CHANGELOG entries below that cite it are left as written.
|
||||
- `docs/architecture.md` gained a *Code layers* section: a diagram of
|
||||
handlers → services → store plus the adapters, and the reason the services
|
||||
layer exists (multi-store writes and their rollback) — closing item A2 of
|
||||
`docs/code-review.md`.
|
||||
- Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale
|
||||
"Phase N" / historical-staging references from code and shell-script
|
||||
comments (`cmd/panel`, `internal/*`, `build/*`) now that v1.0 is done;
|
||||
fixed a stale dashboard comment (`internal/web/handlers_domains.go`)
|
||||
claiming applications/send-log were unimplemented; added a CSRF ADR to
|
||||
`docs/security.md` (why Origin-check, not tokens); resolved `docs/logo` in
|
||||
`docs/roadmap.md` (directory doesn't exist, criterion already met); added a
|
||||
`gofmt -l` check to CI (`.github/workflows/test.yml`).
|
||||
- Phase 2 GUI polish (`docs/code-review.md`): the monitoring pages stop
|
||||
polling while their tab is hidden — the skip is done in an
|
||||
`htmx:beforeRequest` listener (`internal/web/static/panel.js`) rather than
|
||||
htmx's own trigger filter, which is evaluated with `new Function` and would
|
||||
be blocked by the panel's CSP. Dark mode is now a single reassignment of CSS
|
||||
custom properties under `prefers-color-scheme: dark` instead of a cascade of
|
||||
`!important` overrides, and the duplicate `main { max-width }` rule is
|
||||
consolidated into one base rule with documented per-page overrides
|
||||
(`internal/web/static/panel.css`).
|
||||
|
||||
### Security
|
||||
|
||||
- Pre-release security review (plan § D, model Fable, 2026-08-06): full pass
|
||||
over the diff from the v1.0 audit (Phase 11, `bd64e80`) to HEAD plus the
|
||||
complete spec 7.6 checklist. No exploitable findings; one defence-in-depth
|
||||
fix below. Accepted risks in `docs/security.md` unchanged.
|
||||
- `saslpasswd2` argv: the application login is now passed after a `--`
|
||||
end-of-options marker (`internal/app/sasl.go`), so a login starting with
|
||||
`-` (legal under the whitelist) can never be parsed as a flag by getopt.
|
||||
|
||||
### Added
|
||||
|
||||
- Optional password encryption for the two secret-bearing downloads (plan
|
||||
phase 1.5, `docs/code-review.md`): an *Encrypt with a password* checkbox on
|
||||
the full-backup and domain-export forms writes a `.spbk` / `.spde` envelope
|
||||
instead of the plain `.tar.gz` / `.json` — scrypt key derivation and
|
||||
AES-256-GCM over 64 KiB chunks, each authenticated with the header, its
|
||||
counter and an end-of-stream flag, so a truncated or altered file refuses to
|
||||
open (`internal/secretfile`). Unticked, both downloads are byte-for-byte what
|
||||
they were.
|
||||
- Domain import accepts an encrypted export: the envelope is detected by its
|
||||
magic bytes, and a password field appears next to the file picker
|
||||
(`internal/web/handlers_backup.go`, `templates/encrypt_fields.html`).
|
||||
- `selfpost-backup` writes encrypted archives and reads them back:
|
||||
`-decrypt` (with `-i`/`-o`) turns a `.spbk` into the plain `.tar.gz` a
|
||||
restore unpacks. The password comes from `SELFPOST_BACKUP_PASSWORD` or
|
||||
`-password-file`, never from argv.
|
||||
- `TestParseDelivery` covers the exotic mail.log shapes the review asked for
|
||||
(`docs/code-review.md` § 3): a `status=` quoted inside the remote reply, the
|
||||
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
||||
unrecognised status word, a capitalised one, and a cleanup line.
|
||||
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
||||
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
|
||||
`docs/architecture.md` persistence § envelope summary.
|
||||
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
||||
release commit (move to `docs/archive/`, retarget its references in README,
|
||||
docs, Makefile, release workflow and the e2e test comment).
|
||||
- docs: `docs/code-review.md` — phase 1.5 plan for optional password encryption
|
||||
of full backup (`.spbk`) and domain export (`.spde`); checkbox UI pattern;
|
||||
remove session-resurrection-from-backup as accepted risk.
|
||||
- docs: `docs/code-review.md` — full codebase review (architecture, code quality,
|
||||
documentation, GUI, legacy, risks) with prioritized implementation plan and
|
||||
model routing; cross-links in `implementation-plan.md` and `progress.md`.
|
||||
- docs (D6): Docker `HEALTHCHECK` probes `/healthz`; endpoint returns 503 unless
|
||||
opendkim, panel, and postfix are RUNNING (`internal/health.Liveness`).
|
||||
- docs (D6): README *Container health* — scope of `/healthz` vs authenticated Status.
|
||||
- docs (D7): `cmd/panel/envdoc_test.go` — regression test that every
|
||||
`loadConfig` and build-script env key is listed in README documentation.
|
||||
- docs (D8): `docs/architecture.md` — as-built processes, mail path, routes,
|
||||
persistence (verified against code).
|
||||
- docs (D8): `docs/development.md` — local Go workflow, `make e2e`, dev-server
|
||||
loop, commit/CHANGELOG protocol, agent rules.
|
||||
- docs (D9): `docs/product.md` — product purpose, assumptions, out-of-scope,
|
||||
multi-domain model.
|
||||
- docs (D9): `docs/security.md` — self-contained mandatory security checklist
|
||||
(former spec §7.6).
|
||||
- docs (D9): `specification.md` archived to
|
||||
`docs/archive/specification-v1.0.md`; live docs updated (`progress.md`,
|
||||
`implementation-plan.md`, `roadmap.md`, `documentation-plan.md`).
|
||||
- docs (D1): README *Operations* — panel screens (`/status`, domains,
|
||||
deliveries, mail queue, system log, backup, account), upgrade procedure,
|
||||
session behaviour (sliding idle, monitoring polls do not extend, password
|
||||
change signs out other sessions), and `mail.log` rotation cadence.
|
||||
- docs (D1): README *Rate limiting* — level-1 anvil limits
|
||||
(`RATE_LIMIT_MESSAGES_PER_IP`, `RATE_LIMIT_WINDOW_SECONDS`) and level-2
|
||||
per-domain/application limits from the panel; fixes the `.env.example` link
|
||||
that pointed at a missing section.
|
||||
- docs (D2): README environment-variable reference — public `.env` table with
|
||||
code-accurate defaults, `TRUSTED_PROXY_CIDR` security note, explicit
|
||||
internal-variable list; `TRUSTED_PROXY_CIDR` wired through
|
||||
`deploy/docker-compose.yml`.
|
||||
|
||||
### Removed
|
||||
|
||||
- docs: `security.md` — accepted risk «restore old backup revives session rows»
|
||||
(not a concern in operator deployment).
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release
|
||||
security review (§ D); closed B.1–C.4 material moved to as-built and ops docs.
|
||||
- docs: `architecture.md` — sessions (SQLite, idle renew, password change),
|
||||
`mail.log` rotation (rename + `postfix reload`), `SELFPOST_HOSTNAME` startup
|
||||
gate, log-tailer known gaps.
|
||||
- docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow.
|
||||
- docs: `security.md` — accepted risk for send-log rows stuck at `queued` after
|
||||
panel restart or container recreate.
|
||||
- docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail.
|
||||
- docs: `progress.md`, `documentation-plan.md` — cross-links updated for the new layout.
|
||||
- docs: `documentation-plan.md` marked closed (D1–D9); trimmed to package
|
||||
checklist, code-verification method, and ongoing maintenance rules.
|
||||
- docs: `roadmap.md` — v1.x doc/deploy tail (Codeberg Quick start, compose
|
||||
image tag at release, `docs/logo`); archived-spec references replaced with
|
||||
`product.md` / `security.md` / `development.md`.
|
||||
- docs: `progress.md` — documentation pass closed; deferred polish in roadmap.
|
||||
- `/healthz` now checks supervisord mail-path processes, not HTTP alone.
|
||||
- `build/Dockerfile`: `curl` for `HEALTHCHECK`; probe on port 8080.
|
||||
- docs (D3): README backup — stopped-container `tar` of `./data` (with live-container
|
||||
WAL warning), `manifest.json` consumed after a matching restore.
|
||||
- docs (D4): README status banner (v1.0 implemented, links to open questions and
|
||||
documentation pass); new *Published ports* note for 587; compose usage comment
|
||||
corrected (TLS via `./certs` bind mount, not `.env`).
|
||||
- docs (D5): `implementation-plan.md` B.1 — password change signs out other
|
||||
sessions only (implementation diverged from original plan; README was already
|
||||
correct).
|
||||
- docs: documentation plan now targets retiring `specification.md` after D9 —
|
||||
migration map to `product.md`, `architecture.md`, `development.md`, and
|
||||
expanded `security.md`; D9 added to the release gate.
|
||||
|
||||
## [0.4.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- The project's mark is now in use rather than only on file. The README opens
|
||||
with the full stamp; the panel carries the compact one at the left of its
|
||||
navigation bar, linking to the status page, and the full one above the card
|
||||
on the two pages that have no navigation — sign-in and first-run setup. The
|
||||
browser tab icon changes with it, from the earlier envelope drawing to the
|
||||
stamp's small-size variant, so the tab, the panel and the README are one
|
||||
identity. The four brand files in `docs/assets/` had their wordmark converted
|
||||
from live text to outlines: they were set in IBM Plex Sans, which is not
|
||||
installed on the machines that render them, and the light/semibold contrast
|
||||
between *Self* and *Post* — the whole of the mark — collapsed into whatever
|
||||
fallback the viewer happened to have.
|
||||
|
||||
### Changed
|
||||
|
||||
- panel: the sign-in and setup pages are now a column the width of their own
|
||||
card. Both are a single narrow card, which centred itself while the heading
|
||||
above it stayed at the panel's left edge; adding the mark would have made
|
||||
that three alignments on a page with four elements.
|
||||
|
||||
- panel: the three monitoring pages — Deliveries, Mail queue, System log — are
|
||||
now laid out wider (64rem against the 48rem the rest of the panel keeps).
|
||||
They carry data rather than prose: the send-log's seven columns had no room
|
||||
to breathe, and the raw `mail.log` lines wrapped every second line.
|
||||
|
||||
### Fixed
|
||||
|
||||
- panel: Deliveries now shows the subject as text rather than as its MIME
|
||||
encoding. A subject in any non-Latin alphabet reaches the milter as RFC 2047
|
||||
encoded-words (`=?utf-8?Q?=D0=9F…?=`), and the panel printed that verbatim —
|
||||
unreadable, and as one unbreakable run wide enough to push the Status column
|
||||
outside the card. Subjects are decoded when the message is journalled and
|
||||
capped at 200 characters; the column clips anything still too long to one
|
||||
line, with the full text in the tooltip. Rows logged before this release keep
|
||||
their raw string. Subjects in the legacy single-byte charsets (windows-1251,
|
||||
koi8-r) are still stored as sent — there is no decoder for them.
|
||||
|
||||
- panel: table cells may now break inside a word, so no single long value can
|
||||
push a table past the edge of its card. A 40-character recipient address did
|
||||
it just as readily as an undecoded subject: a column is at least as wide as
|
||||
the longest unbreakable run it holds, and email addresses have nothing to
|
||||
break on. Timestamps are exempt and stay on one line.
|
||||
|
||||
- panel: the Applications list on a domain page no longer comes apart. It was a
|
||||
four-column table whose last column held six controls, two of them expanding
|
||||
panels with textareas — far more than the width of a column, so the controls
|
||||
broke into a staircase, the login cell grew into a block as tall as the row,
|
||||
and the two text columns were left stranded on the baseline halfway down it.
|
||||
An application is now a block rather than a row: the login on one line, mode
|
||||
and addresses on the next, and the controls in a single wrapping row, with an
|
||||
opened panel claiming the full width for its fields.
|
||||
|
||||
## [0.3.0] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- panel: the PTR (reverse DNS) check no longer reports a correctly published
|
||||
record as wrong. The checks went through the container's own resolver, which
|
||||
forwards to the host's systemd-resolved — and systemd-resolved answers the
|
||||
reverse lookup of the machine's own IP from the local hostname instead of
|
||||
asking public DNS. A server with `81.30.105.2 → selfpost.example.com` in DNS
|
||||
was told its PTR pointed at the provider-assigned hostname. All four
|
||||
deliverability checks (PTR, SPF, DKIM, DMARC) now query recursive resolvers
|
||||
directly, so the panel reports what a receiving mail server actually sees.
|
||||
Set `SELFPOST_DNS_RESOLVERS` if outbound port 53 is closed or you run your
|
||||
own recursor; it defaults to 1.1.1.1, 8.8.8.8 and 9.9.9.9.
|
||||
|
||||
### Changed
|
||||
|
||||
- panel: the three monitoring pages now live at URLs that match their nav
|
||||
labels — Deliveries at `/deliveries` (was `/sendlog`), Mail queue at
|
||||
`/mail-queue` (was `/queue`), System log at `/system-log` (was `/logtail`).
|
||||
Bookmarks to the old paths stop working.
|
||||
|
||||
- panel: each entry in the navigation bar now carries an icon beside its label,
|
||||
so the bar is scannable at a glance instead of a row of similar-length words.
|
||||
The icons are inline SVG drawn in the entry's own colour — no extra request,
|
||||
no exemption from the panel's Content-Security-Policy — and are hidden from
|
||||
screen readers, which still announce the label alone.
|
||||
|
||||
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
|
||||
user, Account and Sign out along the top right, the page entries below. It no
|
||||
longer fits on one line and used to wrap on its own, which left the session
|
||||
block sitting left-aligned under the entries as if it were more navigation.
|
||||
|
||||
## [0.2.0] - 2026-08-03
|
||||
|
||||
- panel: every authenticated page now ends with the running version
|
||||
(`SelfPost 0.2.0`) in a small footer. It is the value a backup manifest is
|
||||
checked against on restore, and the first thing to establish when the panel
|
||||
behaves unexpectedly. The login and setup pages deliberately do not show it.
|
||||
|
||||
- panel: the domain page now shows the **SPF and DMARC records it expects**,
|
||||
with host, value and a Copy button, next to the DKIM record it already
|
||||
showed — previously it only said "also configure SPF and DMARC (see the
|
||||
documentation)" and the concrete example appeared only once a check had
|
||||
already failed. The SPF value names the addresses this server's hostname
|
||||
resolves to (falling back to an `a:` mechanism if it does not resolve), and
|
||||
the DNS checks below build their remediation advice from the same source, so
|
||||
the page and its checks cannot recommend different records.
|
||||
- panel: one appearance for actions. Several controls — a POST wrapped in an
|
||||
inline form (Re-check, Export domain, Sign out, New password…), the
|
||||
`<details>` toggles in the applications table, the delete links — used to
|
||||
render as bold blue text while everything else was a button, so the same
|
||||
kind of control looked like two different things, sometimes within one card.
|
||||
They are all buttons now: filled for a card's own action, compact and
|
||||
outlined where actions cluster in a table row or the nav bar. The two
|
||||
actions that are really navigations — "Delete domain" and the status page's
|
||||
"Full queue" — are anchors carrying the same button styling. A bare link is
|
||||
left only where it reads as part of a sentence, a table cell or the nav.
|
||||
- panel: on the domain page **Add an application** now sits directly above the
|
||||
**Applications** list — the same order the domains page uses for its own add
|
||||
form — instead of being stranded below the domain rate limit.
|
||||
- ci: hermetic container e2e suite (`test/e2e`, a separate Go module) gates
|
||||
image publishing — `make e2e` locally, and `go test ./...` in `test/e2e` as
|
||||
a required step in `release.yml` before a version tag's image is pushed.
|
||||
It builds the real image, brings up the shipped `deploy/docker-compose.yml`
|
||||
plus a test-only override (self-signed cert, low ports, a fake DNS zone
|
||||
served by CoreDNS, a `smtp-sink` sink-MX) on an isolated compose project,
|
||||
then drives the panel over HTTP exactly like an administrator: setup →
|
||||
login → add a domain → publish the DKIM record it prints into the fake zone
|
||||
→ add an application → send over SMTP AUTH → verify the delivered message's
|
||||
DKIM signature against the record the panel published → poll the send log
|
||||
to `sent`. Negative coverage: no-AUTH and unauthenticated-relay rejection,
|
||||
sender/login mismatch, the level-1 (anvil) and level-2 (panel-configured)
|
||||
rate limits, the journal-milter's fail-open behaviour when the panel process
|
||||
is stopped, a missing/malformed `SELFPOST_HOSTNAME` failing the container
|
||||
fast, and a login session surviving `docker restart`. `release.yml` moved
|
||||
off qemu to a native per-architecture build (`ubuntu-latest` /
|
||||
`ubuntu-24.04-arm`), each gated by this suite before its tag is pushed and
|
||||
merged into the version manifest — running the full Postfix/OpenDKIM stack
|
||||
under emulation for the gate was impractically slow.
|
||||
- ops: `mail.log` rotation switched from `copytruncate` to rename +
|
||||
`postfix reload` (the same mechanism `postfix logrotate` itself uses),
|
||||
eliminating the up-to-one-second window in which `copytruncate` could drop
|
||||
in-flight delivery lines — a lost line meant a send-log row stuck at
|
||||
`queued` forever. `logrotate-mail.conf` keeps `create 0644 root root`
|
||||
rather than `nocreate`: verified on a live container that letting Postfix
|
||||
recreate the file itself on reload produces `0600`, which the unprivileged
|
||||
panel process cannot read, breaking the mail-log view until the next
|
||||
restart. The panel's log-tailer (`internal/logtail`) re-drains the old file
|
||||
descriptor once more right before switching to the rotated one, closing a
|
||||
similar small window between polls; a missing `mail.log` right after
|
||||
rotation is now a normal empty screen rather than a logged error.
|
||||
- panel: login sessions now persist in SQLite instead of memory, so an
|
||||
administrator's login survives a container restart or redeploy. Only the
|
||||
SHA-256 of the session token is stored, never the token itself. The
|
||||
absolute 12-hour TTL is replaced by a **sliding idle timeout**
|
||||
(`PANEL_SESSION_IDLE_DAYS`, default 7 days, no absolute cap): the
|
||||
monitoring screens' background polling does not count as activity, so a
|
||||
forgotten open tab does not keep a session alive forever. Changing the
|
||||
password still signs out every other session.
|
||||
- panel: security headers on every response — `Content-Security-Policy`,
|
||||
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and
|
||||
`Strict-Transport-Security` where the deployment is HTTPS-only. They are
|
||||
emitted by the panel itself, so the reverse proxy still needs no security
|
||||
configuration of its own.
|
||||
- panel: state-changing requests are now checked against the panel's own
|
||||
origin (`Sec-Fetch-Site`, falling back to `Origin` vs `Host`). This closes
|
||||
cross-site request forgery from a *neighbouring host on the same domain* —
|
||||
a CMS or a forgotten staging subdomain next to the panel — which the session
|
||||
cookie's `SameSite=Lax` counts as same-site and therefore cannot stop. A
|
||||
request that sends neither header is still let through, so genuinely ancient
|
||||
browsers keep working. **The reverse proxy must pass the original `Host`
|
||||
header through** (every shipped fragment already does); one that rewrites it
|
||||
makes the panel refuse every form submission, and the log line names both
|
||||
the `Origin` and the `Host` it compared.
|
||||
- panel: the session cookie is now named `__Host-selfpost_session` wherever it
|
||||
is `Secure` (the standard deployment), which makes the browser enforce that
|
||||
no other host can set or overwrite it. **Upgrading signs the administrator
|
||||
out once.** With `PANEL_COOKIE_SECURE=false` the old name is kept, because
|
||||
the prefix is invalid without TLS. Signing out clears both names.
|
||||
- panel: if a request arrives with two cookies of the session cookie's name —
|
||||
what a neighbouring host does when it overwrites the session — the request
|
||||
counts as signed out and the log says so, instead of the panel silently
|
||||
picking the other host's value and looping back to the login form forever.
|
||||
- panel: the layout's stylesheet moved to `/static/panel.css` and the
|
||||
confirmation prompts on destructive buttons moved into `/static/panel.js`.
|
||||
No visible change; the panel's CSP allows no inline script or style, and
|
||||
this is what keeps that policy free of exemptions.
|
||||
- docs: the first-run setup link is also written to `/data/setup-token`
|
||||
(`0600`) — documented in the README as the way to read it without the token
|
||||
passing through a container-log pipeline.
|
||||
- panel: new **Status** page — supervised processes, mail queue, TLS
|
||||
certificate expiry, milter sockets and the server's own hostname/reverse-DNS
|
||||
(FCrDNS) check — and it is now the panel's landing page. The local checks
|
||||
refresh by polling; the DNS lookup is cached with a *Re-check* button.
|
||||
- panel: the domain page shows a **DNS status** card: the published DKIM record
|
||||
compared against the key this server actually signs with, plus SPF and DMARC.
|
||||
The SPF check is deliberately shallow — it looks for a mechanism literally
|
||||
covering this server's address and does not follow `include:`/`redirect=`, so
|
||||
a record that authorises the server through an include is reported as "cannot
|
||||
tell", not as a failure.
|
||||
- panel: the domain list moved from `/` to `/domains`; `/` redirects to the
|
||||
status page. The **Reload** button moved from the domain list to the status
|
||||
page and now explains what it regenerates and when to use it.
|
||||
- fix: the panel could never read the mail queue in the documented deployment.
|
||||
`postqueue` relies on its setgid-`postdrop` bit, which `no-new-privileges`
|
||||
(set in the shipped compose file) disables, so the *Queue* screen always said
|
||||
"Could not read the mail queue". The `panel` user is now a real member of
|
||||
`postdrop`.
|
||||
- panel: navigation bar is now rendered once from the shared layout, so every
|
||||
authenticated page has it — including the domain page and the delete
|
||||
confirmation, which had no navigation links at all — and the current page is
|
||||
highlighted instead of silently missing from the list.
|
||||
- panel: new *Account* page to change the administrator's username and/or
|
||||
password (the current password is required, throttled on the same limiter as
|
||||
the login form). Changing the password invalidates all other sessions.
|
||||
- panel: *Backup & migration* moved off the domain list onto its own *Backup*
|
||||
page, with the full backup and the domain import as two separate cards.
|
||||
- panel: the domain page now shows the *Sending server settings* (server,
|
||||
port and encryption) needed to configure a mail client; port 587 is listed
|
||||
only when `SUBMISSION_ENABLE=true` for this deployment.
|
||||
- panel: *Copy* buttons on the DKIM record, on a newly issued application
|
||||
login/password and on the sending server name.
|
||||
- panel: the *Addresses* field is hidden while an application's address mode is
|
||||
*Any address of the domain*, where the server ignores it.
|
||||
- ci: disable provenance attestation on release image push, so the ghcr.io
|
||||
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
|
||||
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull
|
||||
request, not only the image build on a release tag.
|
||||
- security: optionally honour `X-Forwarded-For` for login/setup rate-limiting
|
||||
when the request's direct peer is in the new `TRUSTED_PROXY_CIDR` list,
|
||||
giving real per-client limits behind a reverse proxy instead of one global
|
||||
bucket. Unset by default (unchanged `RemoteAddr`-only behaviour).
|
||||
|
||||
## [0.1.0] - 2026-07-15
|
||||
|
||||
Initial feature-complete implementation of the v1.0 specification (phases 0-11
|
||||
of `docs/implementation-plan.md`).
|
||||
|
||||
### Added
|
||||
|
||||
- Panel (Go, single static binary) with SQLite persistence, one-time
|
||||
crypto-random setup link, bcrypt admin auth, session cookies.
|
||||
- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
|
||||
OpenDKIM KeyTable/SigningTable regeneration + privilege-safe reload.
|
||||
- Application (sender identity) management: SASL credentials via `sasldb2`,
|
||||
`smtpd_sender_login_maps` enforcing sender/domain ownership, no open relay.
|
||||
- Full Postfix relay config generated from env at container start: SMTPS 465,
|
||||
optional STARTTLS submission 587, SASL auth, TLS for outbound delivery,
|
||||
anvil-based rate limiting (level 1).
|
||||
- Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
|
||||
fail-open by design so a milter fault never blocks mail.
|
||||
- Monitoring UI: send log, Postfix queue, and mail.log tail, all
|
||||
HTMX-polling, HTML-escaped.
|
||||
- Per-domain/per-application sending rate limit (level 2), enforced in the
|
||||
journal milter at `MAIL FROM`, fail-open on the limiter's own errors.
|
||||
- Full backup/restore (`tar.gz` of `/data`, consistent SQLite snapshot via
|
||||
`VACUUM INTO`) with a version guard that refuses to start on a
|
||||
manifest/binary version mismatch. Per-domain export/import for moving a
|
||||
single domain between hosts without re-issuing DNS records.
|
||||
- Deployment: Docker image + compose, reverse-proxy fragments for Apache
|
||||
(default), nginx, Caddy, and Traefik; CI workflow publishing tagged,
|
||||
multi-arch images to `ghcr.io` on `vX.Y.Z` tags.
|
||||
- Security pass against spec 7.6 (exec safety, config-write sanitization,
|
||||
server-side validation, rate limiting, session/cookie hardening, output
|
||||
escaping, non-root panel) — full compliance, no code changes required.
|
||||
- Live production deployment with a real Let's Encrypt certificate;
|
||||
end-to-end delivery confirmed (DKIM pass, SPF pass).
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,41 @@
|
||||
# SelfPost build.
|
||||
#
|
||||
# The version is stamped into both binaries and MUST match the Docker image tag
|
||||
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
|
||||
#
|
||||
# make build VERSION=1.3.0
|
||||
#
|
||||
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||
# Go, so no cgo is required) — see spec 7.1.
|
||||
|
||||
VERSION ?= dev
|
||||
MODULE := github.com/mixeme/selfpost
|
||||
LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION)
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: all build vet test clean e2e
|
||||
|
||||
all: vet build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/panel ./cmd/panel
|
||||
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/selfpost-backup ./cmd/selfpost-backup
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
|
||||
# Hermetic container e2e (see docs/development.md): separate Go module
|
||||
# under test/e2e so its test-only dependencies (DKIM verification) never enter
|
||||
# this module's build graph. Builds the image fresh from this checkout, brings
|
||||
# up deploy/docker-compose.yml plus a test-only override on high ports and an
|
||||
# isolated compose project (-p selfpost-e2e) so it never collides with a real
|
||||
# deployment on the same host, then tears the stand down whether the suite
|
||||
# passed or not.
|
||||
e2e:
|
||||
cd test/e2e && go test -v -timeout 20m ./...
|
||||
@@ -0,0 +1,264 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/selfpost-stamp.svg" alt="SelfPost" width="440">
|
||||
</p>
|
||||
|
||||
# SelfPost
|
||||
|
||||
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
|
||||
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
|
||||
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
|
||||
then point your apps at the SMTP endpoint.
|
||||
|
||||
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
||||
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
||||
|
||||
**For:** operators who run their own VPS or home server and want a simple relay
|
||||
they control, without a third-party SMTP provider.
|
||||
|
||||
**Key properties:** one container, multi-domain DKIM, SASL per application,
|
||||
send log and DNS checks in the panel, encrypted backups.
|
||||
|
||||
## Features
|
||||
|
||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
|
||||
- Full-server backup and single-domain export/import (optional password encryption)
|
||||
- Single Docker image; data in a `./data` bind mount
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
||||
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — internal, Russian |
|
||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||
|
||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||
the `ghcr.io/mixeme/selfpost` image.
|
||||
|
||||
## Requirements
|
||||
|
||||
Providing these is the operator's job — SelfPost cannot fix a blocked port or a
|
||||
missing PTR record for you. Details: [Operator guide](docs/guide.md).
|
||||
|
||||
### Platform
|
||||
|
||||
- Docker + Compose v2 on the host
|
||||
- A reverse proxy in front of the panel (SelfPost never terminates HTTPS itself)
|
||||
- Rough sizing: **1 vCPU**, **512 MB–1 GB RAM**, **8–10 GB disk** (send log and
|
||||
rotated `mail.log` are the main growth drivers; both sit in the `./data`
|
||||
volume, and both are capped — 90 days and 14 files by default)
|
||||
|
||||
### Network and IP
|
||||
|
||||
- [ ] Static IP address
|
||||
- [ ] Outbound TCP port 25 unblocked (many consumer/cloud hosts block it by
|
||||
default)
|
||||
- [ ] PTR/rDNS for that IP pointing at your mail hostname (`SELFPOST_HOSTNAME`)
|
||||
- [ ] Reasonable starting IP reputation — a fresh IP still needs
|
||||
[warmup](docs/guide.md#ip-warmup)
|
||||
|
||||
### Per sending domain
|
||||
|
||||
For every domain you add in the panel:
|
||||
|
||||
- [ ] SPF TXT record authorizing this server
|
||||
- [ ] DKIM TXT record (value shown on the domain page)
|
||||
- [ ] DMARC `_dmarc` TXT record
|
||||
|
||||
See [DNS setup](docs/guide.md#dns-setup) in the operator guide.
|
||||
|
||||
## Quick start
|
||||
|
||||
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
||||
> a **one-time setup URL** (valid ten minutes). Open it in a browser to choose
|
||||
> the administrator username and password. Until you do, the panel has no login.
|
||||
> Production deploy: [step 3](#3-start-selfpost).
|
||||
|
||||
One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
||||
files, no compose files. Good for clicking through the UI on your machine;
|
||||
outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
|
||||
```sh
|
||||
docker run --rm -d --name selfpost-try \
|
||||
-p 127.0.0.1:8080:8080 \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:1.0.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
```sh
|
||||
docker logs selfpost-try 2>&1 | grep -m1 'http'
|
||||
```
|
||||
|
||||
```sh
|
||||
docker exec selfpost-try cat /data/setup-token
|
||||
```
|
||||
|
||||
The printed URL is `https://mail.local.test/setup/<token>`. For this local
|
||||
trial rewrite it to `http://127.0.0.1:8080/setup/<token>` (same path token;
|
||||
`PANEL_COOKIE_SECURE=false` so the cookie works over plain HTTP). Open it
|
||||
before it expires.
|
||||
|
||||
When finished:
|
||||
|
||||
```sh
|
||||
docker rm -f selfpost-try && docker volume rm selfpost-try-data
|
||||
```
|
||||
|
||||
More detail (limitations, optional throwaway TLS for local SMTP): [Local
|
||||
trial](docs/guide.md#local-trial) in the operator guide.
|
||||
|
||||
## Reference deploy
|
||||
|
||||
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
|
||||
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
|
||||
only through a reverse proxy on 443 — port 8080 is bound to localhost in the
|
||||
default compose file.
|
||||
|
||||
| Artefact | Path |
|
||||
|---|---|
|
||||
| Compose file (fixed image tag) | [deploy/docker-compose.yml](deploy/docker-compose.yml) |
|
||||
| Environment template | [deploy/.env.example](deploy/.env.example) |
|
||||
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) |
|
||||
| nginx | [deploy/nginx/](deploy/nginx/) |
|
||||
| Caddy | [deploy/caddy/](deploy/caddy/) |
|
||||
| Traefik | [deploy/traefik/](deploy/traefik/) |
|
||||
|
||||
### 1. Fetch the base files
|
||||
|
||||
```sh
|
||||
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
|
||||
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
|
||||
your provider and the certificate your proxy will obtain.
|
||||
|
||||
### 2. Reverse proxy and TLS
|
||||
|
||||
Pick one proxy. In every case the proxy terminates HTTPS for the panel; the
|
||||
same certificate must end up under `./certs` as `fullchain.pem` and
|
||||
`privkey.pem` so Postfix can serve it on 465 (and 587 if enabled). The proxy
|
||||
must **pass the original `Host` header** — details and rationale:
|
||||
[Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||
|
||||
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
|
||||
`proxy_http` enabled. Copy
|
||||
[deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) into your
|
||||
vhost directory, replace `mail.example.com` with your hostname, enable the site,
|
||||
then issue a certificate:
|
||||
|
||||
```sh
|
||||
sudo certbot --apache -d mail.example.com
|
||||
```
|
||||
|
||||
Point `./certs` at the PEM files certbot wrote (symlink is fine):
|
||||
|
||||
```sh
|
||||
ln -s /etc/letsencrypt/live/mail.example.com certs
|
||||
```
|
||||
|
||||
**nginx (containerised).** From the `deploy/` directory, merge the nginx
|
||||
fragment and issue the first certificate before nginx can serve HTTPS:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
|
||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
```
|
||||
|
||||
Edit [deploy/nginx/nginx.conf.example](deploy/nginx/nginx.conf.example) and
|
||||
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
|
||||
both nginx and SelfPost.
|
||||
|
||||
**Caddy (containerised, automatic ACME).** Edit
|
||||
[deploy/caddy/Caddyfile](deploy/caddy/Caddyfile) and the `<hostname>` placeholders
|
||||
in [deploy/caddy/docker-compose.caddy.yml](deploy/caddy/docker-compose.caddy.yml),
|
||||
then:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||
```
|
||||
|
||||
Verify Caddy's on-disk cert path for your version before relying on the
|
||||
default mount — see the comment at the top of the Caddy compose fragment.
|
||||
|
||||
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
|
||||
[deploy/traefik/docker-compose.traefik.yml](deploy/traefik/docker-compose.traefik.yml),
|
||||
start the stack, then extract PEM files for Postfix whenever Traefik issues or
|
||||
renews a certificate:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
|
||||
```
|
||||
|
||||
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
||||
|
||||
### 3. Start SelfPost
|
||||
|
||||
If you used Apache on the host (step 2, first option), start only the base
|
||||
compose file from your `selfpost/` directory:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The nginx/Caddy/Traefik fragments from step 2 already include `docker compose up
|
||||
-d` — skip this if you ran one of those.
|
||||
|
||||
**Get the setup URL** — open it in a browser to create the admin account
|
||||
([first boot](#quick-start)):
|
||||
|
||||
```sh
|
||||
docker compose logs selfpost 2>&1 | grep -m1 'http'
|
||||
```
|
||||
|
||||
```sh
|
||||
cat ./data/setup-token
|
||||
```
|
||||
|
||||
The file is deleted as soon as setup completes. If logs are shipped to a
|
||||
central aggregator, prefer `cat ./data/setup-token` so the bearer token does
|
||||
not enter the log pipeline.
|
||||
|
||||
### 4. DNS and sending
|
||||
|
||||
Before sending real mail:
|
||||
|
||||
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
|
||||
page → *Re-check*).
|
||||
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
||||
same time ([DNS setup](docs/guide.md#dns-setup)).
|
||||
3. Warm up a new IP gradually ([IP warmup](docs/guide.md#ip-warmup)).
|
||||
|
||||
### Ports and upgrades
|
||||
|
||||
The compose file maps **465** (always) and **587** (when
|
||||
`SUBMISSION_ENABLE=true`). Bump the pinned image tag deliberately when
|
||||
upgrading — never use `:latest` ([why](docs/guide.md#fixed-image-tag)).
|
||||
|
||||
Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see
|
||||
[Environment variables](docs/guide.md#environment-variables).
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
||||
version as a network-accessible service, you must make the modified source
|
||||
available to its users — not only when you distribute copies of the code.
|
||||
@@ -0,0 +1,114 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||
# supervisord (spec 4). Build from the repository root:
|
||||
#
|
||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
|
||||
# ---- build stage -------------------------------------------------------------
|
||||
FROM golang:1.26-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||
ARG VERSION=dev
|
||||
|
||||
# Module metadata first for layer caching.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go vet ./... \
|
||||
&& go build -trimpath \
|
||||
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
|
||||
-o /out/panel ./cmd/panel \
|
||||
&& go build -trimpath \
|
||||
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
|
||||
-o /out/selfpost-backup ./cmd/selfpost-backup
|
||||
|
||||
# ---- runtime stage -----------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Preseed Postfix so its install is non-interactive and yields a working
|
||||
# main.cf. The real relay configuration is generated by the panel.
|
||||
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postfix \
|
||||
opendkim \
|
||||
opendkim-tools \
|
||||
sasl2-bin \
|
||||
libsasl2-modules \
|
||||
db-util \
|
||||
supervisor \
|
||||
logrotate \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Unprivileged user for the panel process (spec 7.6.8).
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||
|
||||
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
||||
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
||||
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
||||
# them. Membership in this group — plus setgid dirs under /data (set up in
|
||||
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||
RUN groupadd --system selfpost \
|
||||
&& usermod -aG selfpost panel \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
||||
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
||||
# it the group needed to reach Postfix's showq socket — but the documented
|
||||
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
||||
# so the panel would always see "Permission denied". Making `panel` a real
|
||||
# member of postdrop grants the same access without relying on a setgid
|
||||
# escalation the hardening deliberately forbids. postdrop membership is
|
||||
# read-side only: it does not let the panel bypass any Postfix restriction that
|
||||
# a local user does not already have through the world-executable sendmail.
|
||||
RUN usermod -aG postdrop panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||
&& chown opendkim:opendkim /run/opendkim \
|
||||
&& chown panel:panel /run/selfpost /data
|
||||
|
||||
COPY --from=build /out/panel /usr/local/bin/panel
|
||||
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||
|
||||
COPY build/opendkim.conf /etc/opendkim.conf
|
||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||
COPY build/crashexit.py /usr/local/bin/crashexit.py
|
||||
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \
|
||||
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
|
||||
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||
# which needs no inbound listener or EXPOSE.
|
||||
EXPOSE 8080 465 587
|
||||
|
||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||
|
||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervisor event listener: bring the container down on unrecoverable failure.
|
||||
|
||||
If any managed program exhausts its restart retries and enters FATAL, this
|
||||
listener signals supervisord (its parent) to terminate, so the whole container
|
||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
|
||||
|
||||
Communication with supervisord uses the event listener protocol over stdin and
|
||||
stdout, so stdout must carry only protocol tokens.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
|
||||
def write_stdout(s):
|
||||
sys.stdout.write(s)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
while True:
|
||||
# Tell supervisord we are ready for the next event.
|
||||
write_stdout("READY\n")
|
||||
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return
|
||||
headers = dict(pair.split(":", 1) for pair in line.split())
|
||||
payload_len = int(headers.get("len", 0))
|
||||
if payload_len:
|
||||
sys.stdin.read(payload_len)
|
||||
|
||||
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
|
||||
# program can no longer be restarted. Take the container down.
|
||||
sys.stderr.write(
|
||||
"crashexit: a managed process entered FATAL; shutting down container\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os.kill(os.getppid(), signal.SIGTERM)
|
||||
|
||||
write_stdout("RESULT 2\nOK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
|
||||
#
|
||||
# SELFPOST_HOSTNAME is checked first (plan B.3): it must fail fast with a clear
|
||||
# message before /data normalisation or Postfix config, so a bad identity never
|
||||
# looks like a permissions or packaging problem (and so the e2e hostname-gate
|
||||
# tests see the FATAL text rather than an earlier set -e abort).
|
||||
set -e
|
||||
|
||||
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
||||
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||
# postfix-config.sh each fall back independently when it's unset — to
|
||||
# `localhost` and to the container hostname respectively — so accounts get
|
||||
# written under one realm and looked up under another and authentication
|
||||
# silently fails for every application, while HELO also stops matching the
|
||||
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||
# correct, so fail loudly here, before either side of that split has a chance
|
||||
# to run, rather than leave a green panel with broken mail.
|
||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||
cat >&2 <<'EOF'
|
||||
FATAL: SELFPOST_HOSTNAME is not set.
|
||||
|
||||
This is the mail server's identity: it becomes the Postfix HELO/EHLO name,
|
||||
the Cyrus SASL realm that application passwords are looked up under, and it
|
||||
must match the TLS certificate's CN/SAN as well as this server's PTR (reverse
|
||||
DNS) record. There is no safe default — guessing any one of these wrong
|
||||
breaks authentication for every application or sends outgoing mail to spam,
|
||||
silently.
|
||||
|
||||
Set it to the mail server's fully-qualified domain name, e.g.:
|
||||
|
||||
SELFPOST_HOSTNAME=mail.example.com
|
||||
|
||||
in the .env file next to your docker-compose.yml (see deploy/.env.example).
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$SELFPOST_HOSTNAME" in
|
||||
*[\ \ ]* | *://* | *:* )
|
||||
echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2
|
||||
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||
exit 1
|
||||
;;
|
||||
*.*)
|
||||
;;
|
||||
*)
|
||||
echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2
|
||||
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||
# by the host user (typically root), not by the unprivileged panel user that
|
||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||
# Fix its ownership here — the one place still running as root — before handing
|
||||
# off to supervisord, which starts the panel as the panel user.
|
||||
#
|
||||
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||
chown panel:panel /data
|
||||
chmod 755 /data
|
||||
# Restored backups or previously-created state may contain panel-owned files
|
||||
# under /data; make sure they stay writable without disturbing anything that a
|
||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||
# normalised on its own below.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
|
||||
|
||||
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||
# just restored from a backup:
|
||||
# - group `selfpost` + setgid on directories so anything the panel creates
|
||||
# inherits the shared group OpenDKIM traverses;
|
||||
# - private keys and tables group-readable (0640);
|
||||
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
|
||||
# daemon comes up cleanly with no domains yet.
|
||||
mkdir -p /data/opendkim/keys
|
||||
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
|
||||
[ -e "$t" ] || : > "$t"
|
||||
done
|
||||
chown -R panel:selfpost /data/opendkim
|
||||
find /data/opendkim -type d -exec chmod 2750 {} +
|
||||
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||
|
||||
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
|
||||
# setgid directory so new files inherit the group, and the database itself
|
||||
# group-readable (0640).
|
||||
mkdir -p /data/sasl
|
||||
chown -R panel:selfpost /data/sasl
|
||||
chmod 2750 /data/sasl
|
||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||
|
||||
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
||||
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
||||
# references it never fails on a missing file, and keep it group-readable.
|
||||
mkdir -p /data/postfix
|
||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||
chown -R panel:selfpost /data/postfix
|
||||
chmod 2750 /data/postfix
|
||||
chmod 0640 /data/postfix/sender_login_maps
|
||||
|
||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||
# `postfix`; the panel reads it for the log-tailer and the System log page. It
|
||||
# lives under /data — not the ephemeral /var/log — so the delivery lines that
|
||||
# resolve a "queued" send-log row survive a container recreate.
|
||||
#
|
||||
# postlogd creates a missing log itself, but at 0600, which the unprivileged
|
||||
# panel cannot read; so create it here (and re-normalise an existing one, plus
|
||||
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
|
||||
# directory keeps the shared group on anything created inside it later, and
|
||||
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
|
||||
# rotate a log whose directory is writable by a non-root group.
|
||||
mkdir -p /data/log
|
||||
[ -e /data/log/mail.log ] || : > /data/log/mail.log
|
||||
chown -R postfix:selfpost /data/log
|
||||
chmod 2750 /data/log
|
||||
find /data/log -type f -exec chmod 0640 {} +
|
||||
|
||||
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
|
||||
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
|
||||
# journal-milter — not just probe them at start-up. The sockets are
|
||||
# created by the opendkim and panel users respectively, so bridge them to
|
||||
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
|
||||
# each socket created inside inherits group `selfpost`, and group-traversable
|
||||
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
|
||||
# because signing is strict (default_action=tempfail), rejects all mail.
|
||||
mkdir -p /run/opendkim /run/selfpost
|
||||
chown opendkim:selfpost /run/opendkim
|
||||
chown panel:selfpost /run/selfpost
|
||||
chmod 2750 /run/opendkim /run/selfpost
|
||||
|
||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||
# optional 587 service are all driven by env at run time, and re-derived on every
|
||||
# start the same way the /data normalisation above is.
|
||||
/usr/local/bin/postfix-config.sh
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
||||
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
||||
# panel's log-tailer holds its own descriptor on that inode, so nothing
|
||||
# written before the reload is lost. `create` (rather than `nocreate`) matters
|
||||
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
|
||||
# owned by postfix, which the unprivileged panel process cannot read —
|
||||
# confirmed on a live container — so logrotate must be the one to create it.
|
||||
#
|
||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
||||
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
|
||||
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||
|
||||
while true; do
|
||||
if logrotate /etc/logrotate.d/mail; then
|
||||
:
|
||||
else
|
||||
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
||||
fi
|
||||
sleep "${INTERVAL}"
|
||||
done
|
||||
@@ -0,0 +1,18 @@
|
||||
# The delivery log lives under the persistent /data, not the ephemeral
|
||||
# /var/log, so the lines that resolve a "queued" send-log row outlive the
|
||||
# container (architecture.md § Log tailer). Path and ownership match
|
||||
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
|
||||
# writes it as user postfix, the unprivileged panel reads it through the shared
|
||||
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
|
||||
/data/log/mail.log {
|
||||
daily
|
||||
rotate 14
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
create 0640 postfix selfpost
|
||||
postrotate
|
||||
/usr/sbin/postfix reload
|
||||
endscript
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenDKIM — per-domain signing (spec 6).
|
||||
#
|
||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
||||
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
||||
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5).
|
||||
#
|
||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
||||
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||
# signs nothing until the first domain is added.
|
||||
|
||||
Syslog no
|
||||
UMask 007
|
||||
Mode s
|
||||
|
||||
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
|
||||
# local-part for a domain; KeyTable maps each domain to its key and selector.
|
||||
KeyTable /data/opendkim/KeyTable
|
||||
SigningTable refile:/data/opendkim/SigningTable
|
||||
|
||||
# The private keys are owned by `panel` and read by `opendkim` through the shared
|
||||
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
|
||||
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
|
||||
# key-safety check, so the check is disabled here by design.
|
||||
RequireSafeKeys no
|
||||
|
||||
Socket local:/run/opendkim/opendkim.sock
|
||||
PidFile /run/opendkim/opendkim.pid
|
||||
UserID opendkim
|
||||
Background no
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
|
||||
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
|
||||
# few months; Postfix only re-reads them on reload. A simple daily reload is
|
||||
# more than enough (a day of staleness is harmless) and far simpler than an
|
||||
# inotify watcher — the spec explicitly prefers this.
|
||||
#
|
||||
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
|
||||
# first, then reloads in a loop: no reload at container start (the wrapper is
|
||||
# still bringing Postfix up then) and none until at least one interval has
|
||||
# passed. A reload is harmless when nothing changed.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
|
||||
|
||||
while true; do
|
||||
sleep "${INTERVAL}"
|
||||
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
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/bin/sh
|
||||
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
||||
#
|
||||
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
||||
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
|
||||
# re-derived from the environment on each start rather than persisted, so a
|
||||
# fresh, restarted or restored container always ends up with the same config
|
||||
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||
# is generated separately by the panel — spec 5.1).
|
||||
#
|
||||
# main.cf is written with `postconf -e`, the master.cf submission services with
|
||||
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
|
||||
# a fixed literal or a container environment variable (spec 7.6.3).
|
||||
set -eu
|
||||
|
||||
# --- environment knobs (spec 8) ----------------------------------------------
|
||||
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
|
||||
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||
# fails. Fall back to the container hostname only outside a real deployment.
|
||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
|
||||
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||
# master still starts but TLS handshakes on 465 fail until they appear.
|
||||
#
|
||||
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||
# copy into a writable internal dir and normalise ownership before postconf
|
||||
# and `postfix check`.
|
||||
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||
TLS_CERT="$TLS_CERT_SRC"
|
||||
TLS_KEY="$TLS_KEY_SRC"
|
||||
if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
||||
mkdir -p "$TLS_INTERNAL_DIR"
|
||||
cp -f "$TLS_CERT_SRC" "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||
cp -f "$TLS_KEY_SRC" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||
chown root:root "$TLS_INTERNAL_DIR/fullchain.pem" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||
chmod 0644 "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||
chmod 0600 "$TLS_INTERNAL_DIR/privkey.pem"
|
||||
TLS_CERT="$TLS_INTERNAL_DIR/fullchain.pem"
|
||||
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||
fi
|
||||
|
||||
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||
# defaults, sensible during IP warm-up (spec 10).
|
||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
|
||||
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
|
||||
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
|
||||
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||
|
||||
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||
|
||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
||||
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||
# for messages still marked "queued" survive a container recreate — without
|
||||
# them those rows could never be resolved (architecture.md § Log tailer). The
|
||||
# default must match cmd/panel/main.go's MAIL_LOG; entrypoint.sh creates the
|
||||
# directory and the file with the ownership postlogd writes and the panel reads.
|
||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||
|
||||
# --- main.cf -----------------------------------------------------------------
|
||||
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||
postconf -e \
|
||||
"myhostname=${HOSTNAME_VALUE}" \
|
||||
"maillog_file=${MAIL_LOG_PATH}" \
|
||||
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||
"mydestination=" \
|
||||
"relayhost=" \
|
||||
"inet_interfaces=all" \
|
||||
"inet_protocols=all"
|
||||
|
||||
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||
# image cannot lose the service.
|
||||
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||
|
||||
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||
# these so a misfiled recipient never gets delivered locally.
|
||||
postconf -e \
|
||||
"local_recipient_maps=" \
|
||||
"alias_maps=" \
|
||||
"alias_database="
|
||||
|
||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
||||
postconf -e \
|
||||
"smtp_tls_security_level=may" \
|
||||
"smtp_tls_note_starttls_offer=yes"
|
||||
|
||||
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
||||
# guarantees credentials are never accepted before TLS is up on any port.
|
||||
postconf -e \
|
||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||
"smtpd_tls_security_level=may" \
|
||||
"smtpd_tls_auth_only=yes" \
|
||||
"smtpd_tls_loglevel=1"
|
||||
|
||||
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
|
||||
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
|
||||
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
|
||||
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
|
||||
# as the server realm, which equals the realm the accounts were created under.
|
||||
postconf -e \
|
||||
"smtpd_sasl_auth_enable=yes" \
|
||||
"smtpd_sasl_type=cyrus" \
|
||||
"smtpd_sasl_path=smtpd" \
|
||||
"smtpd_sasl_local_domain=" \
|
||||
"smtpd_sasl_security_options=noanonymous" \
|
||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||
"broken_sasl_auth_clients=yes"
|
||||
|
||||
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
|
||||
# resolves the full address first, then the "@domain" wildcard, so both address
|
||||
# modes work from the same map.
|
||||
postconf -e \
|
||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
||||
|
||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||
# and every authenticated sender address must be owned by its login. NO
|
||||
# permit_mynetworks anywhere — authorisation is by credentials, never by network
|
||||
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
||||
postconf -e \
|
||||
"smtpd_helo_required=yes" \
|
||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||
|
||||
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
||||
# if the journal-milter (level 2) is down.
|
||||
postconf -e \
|
||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||
|
||||
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
|
||||
# (default_action=tempfail: if it is unreachable, defer rather than send
|
||||
# unsigned). The journal-milter is monitoring only and is fail-open
|
||||
# (default_action=accept): its failure must never block the relay. Per-milter
|
||||
# settings use Postfix 3.0+ brace syntax.
|
||||
postconf -e \
|
||||
"milter_protocol=6" \
|
||||
"milter_default_action=tempfail" \
|
||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters="
|
||||
|
||||
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
|
||||
# replies) must fail open just like a crash, not stall mail acceptance until the
|
||||
# Postfix defaults (300s content) elapse. With default_action per milter, a
|
||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||
# but within seconds rather than minutes. Values are well above any healthy
|
||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||
# operation.
|
||||
postconf -e \
|
||||
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
|
||||
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
|
||||
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||
|
||||
# --- master.cf: inbound submission services ----------------------------------
|
||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
||||
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||
# and the Cyrus config outside any chroot.
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"smtps/inet/smtpd_tls_wrappermode=yes" \
|
||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
|
||||
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
|
||||
# the only difference is TLS is negotiated via STARTTLS, so require encryption
|
||||
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
|
||||
# restart after disabling it does not leave the port listening.
|
||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"submission/inet/smtpd_tls_security_level=encrypt" \
|
||||
"submission/inet/smtpd_sasl_auth_enable=yes" \
|
||||
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||
else
|
||||
postconf -MX "submission/inet" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
||||
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
||||
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||
# never leaves. Inside a container the chroot buys little (the container is the
|
||||
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
|
||||
# uniformly. Our own smtps/submission services are already n; this covers the
|
||||
# delivery agents and the rest.
|
||||
postconf -F "*/*/chroot=n"
|
||||
|
||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
|
||||
# only — both are safe because TLS is mandatory before auth on every port.
|
||||
mkdir -p /etc/postfix/sasl
|
||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||
pwcheck_method: auxprop
|
||||
auxprop_plugin: sasldb
|
||||
sasldb_path: ${SASLDB_PATH}
|
||||
mech_list: PLAIN LOGIN
|
||||
EOF
|
||||
|
||||
# Validate the generated configuration; fail loudly if postconf produced
|
||||
# anything Postfix rejects, before the wrapper tries to start it.
|
||||
set +e
|
||||
check_out=$(postfix check 2>&1)
|
||||
ec=$?
|
||||
set -e
|
||||
if [ "$ec" -ne 0 ]; then
|
||||
echo "postfix-config: postfix check failed exit $ec" >&2
|
||||
printf '%s\n' "$check_out" >&2
|
||||
exit "$ec"
|
||||
fi
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
||||
# try to reach the milter sockets before they are listening.
|
||||
#
|
||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
||||
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
|
||||
# an explicit startup failure instead of a relay running blind.
|
||||
#
|
||||
# This handles cold start only. Runtime milter failures after a successful start
|
||||
# are governed by fail-open (milter_default_action).
|
||||
set -eu
|
||||
|
||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
|
||||
INTERVAL=1
|
||||
|
||||
elapsed=0
|
||||
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
|
||||
while [ ! -S "$sock" ]; do
|
||||
if [ "$elapsed" -ge "$TIMEOUT" ]; then
|
||||
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
elapsed=$((elapsed + INTERVAL))
|
||||
done
|
||||
echo "postfix-wrapper: milter socket ready: $sock"
|
||||
done
|
||||
|
||||
echo "postfix-wrapper: both milter sockets ready, starting postfix"
|
||||
exec postfix start-fg
|
||||
@@ -0,0 +1,124 @@
|
||||
; SelfPost process supervision (spec 4).
|
||||
;
|
||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||
; blocks until both milter sockets are ready before starting Postfix.
|
||||
;
|
||||
; If any managed process exhausts its restart retries (FATAL), the crashexit
|
||||
; event listener brings the whole container down so Docker's restart policy can
|
||||
; recreate it cleanly, rather than lingering with a dead component.
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
||||
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
|
||||
; still unreachable by world; only root and the two SelfPost services are in the
|
||||
; group. No HTTP auth is configured because access is gated by socket
|
||||
; permissions, not the network.
|
||||
[unix_http_server]
|
||||
file=/run/supervisor.sock
|
||||
chown=root:selfpost
|
||||
chmod=0770
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisor.sock
|
||||
|
||||
[program:opendkim]
|
||||
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
|
||||
priority=100
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:panel]
|
||||
command=/usr/local/bin/panel
|
||||
user=panel
|
||||
priority=200
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:postfix]
|
||||
command=/usr/local/bin/postfix-wrapper.sh
|
||||
priority=300
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
|
||||
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
|
||||
; directly: `postfix start-fg` forks a separate master process, so a signal to
|
||||
; the supervised foreground process never reaches it (unlike OpenDKIM, which
|
||||
; runs in the foreground as the supervised process itself). Instead the panel
|
||||
; asks supervisord — over the group-accessible control socket — to run this
|
||||
; program, which executes the canonical `postfix reload` as root. autostart is
|
||||
; off (it only runs on demand) and a fast, clean exit is expected, so it never
|
||||
; trips the crashexit listener.
|
||||
[program:postfix-reload]
|
||||
command=/usr/sbin/postfix reload
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=1
|
||||
exitcodes=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
||||
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:cert-reload]
|
||||
command=/usr/local/bin/postfix-cert-reload.sh
|
||||
priority=400
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
||||
; in the image). Runs as root so logrotate can read/rotate the log; never exits
|
||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||
[program:logrotate]
|
||||
command=/usr/local/bin/logrotate-loop.sh
|
||||
priority=400
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=0
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[eventlistener:crashexit]
|
||||
command=/usr/local/bin/crashexit.py
|
||||
events=PROCESS_STATE_FATAL
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// documentedPublic matches docs/guide.md "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
}
|
||||
|
||||
// documentedInternal matches guide.md "Internal variables (not part of the operator interface)".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web"
|
||||
)
|
||||
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
||||
// service delegates to them when a domain (and its applications) is deleted.
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||
|
||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
DNSResolvers: cfg.dnsResolvers,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := srvApp.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("http panel listening on %s", cfg.httpAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||
// (architecture.md § Mail path), recording accepted messages into the send
|
||||
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
||||
// here; the protocol handler lives in internal/milter.
|
||||
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
socketPath := cfg.journalSocket
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("journal-milter listening on %s", socketPath)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Command panel is the SelfPost control panel. This single binary combines
|
||||
// several roles (architecture.md § Image and processes) as a supervised
|
||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||
// the rate-limit checks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(buildinfo.Version)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("panel: ")
|
||||
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("fatal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// config holds the runtime knobs the panel reads from the environment. Every
|
||||
// value has a safe default so the binary also runs outside the container.
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
dataDir string
|
||||
dbPath string
|
||||
manifestPath string
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
sessionIdleDays int
|
||||
dnsResolvers []string
|
||||
|
||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||
// serves and the two milter sockets it connects to. The defaults mirror
|
||||
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
|
||||
saslDBPath string
|
||||
saslRealm string
|
||||
postfixDir string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
// Postfix's delivery log, under /data so the lines that resolve a
|
||||
// "queued" send-log row outlive the container. The default must match
|
||||
// maillog_file in build/postfix-config.sh.
|
||||
mailLog: envDefault("MAIL_LOG", "/data/log/mail.log"),
|
||||
// Send-log retention window (architecture.md § Persistence).
|
||||
// Non-positive/invalid falls back to the 90-day default inside the
|
||||
// log-tailer.
|
||||
retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),
|
||||
|
||||
dataDir: dataDir,
|
||||
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
|
||||
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
// Whether this deployment also runs the 587 submission listener. The
|
||||
// panel only displays it as a client connection setting; the comparison
|
||||
// matches postfix-config.sh, which enables the listener on "true" alone.
|
||||
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
|
||||
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
||||
// rate-limiting. Empty by default: an untrusted peer's
|
||||
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
||||
// told which proxy to trust.
|
||||
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
||||
// Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid
|
||||
// falls back to the 7-day default inside internal/web.
|
||||
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
||||
// Recursive resolvers the deliverability checks query directly. Empty
|
||||
// means dnscheck's public defaults; a closed network names its own here.
|
||||
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
|
||||
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||
|
||||
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||
// Mail path), both under /data so they survive restarts. The SASL realm
|
||||
// defaults to the server hostname so account identities line up with
|
||||
// Postfix's SASL configuration; it falls back to localhost outside the
|
||||
// container.
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
}
|
||||
}
|
||||
|
||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||
// login resolves to the right account.
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
}
|
||||
if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" {
|
||||
return h
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envInt reads an integer environment variable, returning def if it is unset or
|
||||
// not a valid integer.
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
log.Printf("ignoring invalid %s=%q, using %d", key, v, def)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
|
||||
// accepted and treated as /32 or /128). Invalid entries are logged and
|
||||
// skipped rather than failing startup, matching envInt's tolerance of
|
||||
// misconfiguration.
|
||||
func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var nets []*net.IPNet
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
cidr := strings.TrimSpace(part)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
cidr += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
nets = append(nets, n)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all
|
||||
// roles; a role error cancels the others and is returned so the process exits
|
||||
// non-zero (letting supervisord/Docker see the failure — architecture.md §
|
||||
// Image and processes).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||
|
||||
// Restore version guard (architecture.md § Persistence): if a backup was
|
||||
// extracted into /data, its manifest version must match this binary before we
|
||||
// touch the database, so schema/format skew between versions cannot corrupt
|
||||
// the restored state. A match consumes the manifest; its absence is the
|
||||
// normal (non-restore) case.
|
||||
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One database handle shared by every role. The store serialises writes
|
||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||
// can all use it without stepping on each other under WAL.
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errc := make(chan error, 3)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
wg.Add(1)
|
||||
go func(name string, fn func(context.Context) error) {
|
||||
defer wg.Done()
|
||||
if err := fn(ctx); err != nil {
|
||||
errc <- fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
}(r.name, r.fn)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("shutdown signal received, stopping")
|
||||
wg.Wait()
|
||||
log.Printf("panel stopped cleanly")
|
||||
return nil
|
||||
case err := <-errc:
|
||||
log.Printf("role failed: %v", err)
|
||||
stop() // cancel ctx so the other roles wind down
|
||||
wg.Wait()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Command selfpost-backup produces the full persistent-state archive from inside
|
||||
// the container, invoked via `docker exec` for scripted/cron backups — the CLI
|
||||
// equivalent of the panel's backup button (architecture.md § Persistence).
|
||||
//
|
||||
// By default the gzip-compressed tar is written to stdout, so the usual form is:
|
||||
//
|
||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
//
|
||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
||||
// (architecture.md § Persistence).
|
||||
//
|
||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||
// argument, which would show up in the process list) the archive is written as
|
||||
// an encrypted .spbk envelope instead. Turn one back into a plain .tar.gz with
|
||||
// the same password:
|
||||
//
|
||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
)
|
||||
|
||||
// passwordEnv names the environment variable holding the encryption password.
|
||||
// A password must never be a command-line argument: the process list is
|
||||
// readable by every process in the container.
|
||||
const passwordEnv = "SELFPOST_BACKUP_PASSWORD"
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
out := flag.String("o", "", "write the output to this file instead of stdout")
|
||||
in := flag.String("i", "", "read the encrypted archive from this file instead of stdin (-decrypt only)")
|
||||
decrypt := flag.Bool("decrypt", false, "decrypt an encrypted backup (.spbk) back to a plain .tar.gz")
|
||||
pwFile := flag.String("password-file", "", "read the encryption password from this file (first line); "+passwordEnv+" is used when unset")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(buildinfo.Version)
|
||||
return
|
||||
}
|
||||
|
||||
password, err := readPassword(*pwFile)
|
||||
if err == nil {
|
||||
if *decrypt {
|
||||
err = runDecrypt(*in, *out, password)
|
||||
} else {
|
||||
err = run(*out, password)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run writes a backup, encrypting it when a password was supplied.
|
||||
func run(outPath, password string) error {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
|
||||
sink := w
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sink = env
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if env != nil {
|
||||
if err := env.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outPath != "" {
|
||||
kind := "plain"
|
||||
if password != "" {
|
||||
kind = "encrypted"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (%s, SelfPost %s)\n", outPath, kind, buildinfo.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDecrypt turns a .spbk envelope back into the plain gzip tar, so an
|
||||
// encrypted backup can be extracted with ordinary tools during a restore.
|
||||
func runDecrypt(inPath, outPath, password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("-decrypt needs the password (set %s or use -password-file)", passwordEnv)
|
||||
}
|
||||
|
||||
r := io.Reader(os.Stdin)
|
||||
if inPath != "" {
|
||||
f, err := os.Open(inPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
|
||||
env, err := secretfile.NewReader(r, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if env.Type() != secretfile.TypeFullBackup {
|
||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||
}
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
|
||||
if _, err := io.Copy(w, env); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (decrypted)\n", outPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||
if outPath == "" {
|
||||
return os.Stdout, func() {}, nil
|
||||
}
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
}
|
||||
|
||||
// readPassword takes the password from the given file (first line) or, when no
|
||||
// file is named, from the environment. An empty result means "no encryption".
|
||||
func readPassword(pwFile string) (string, error) {
|
||||
if pwFile == "" {
|
||||
return os.Getenv(passwordEnv), nil
|
||||
}
|
||||
data, err := os.ReadFile(pwFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
// A password file is usually written with a trailing newline; take the first
|
||||
// line and strip the line ending, but keep any other whitespace, which may
|
||||
// well be part of the password.
|
||||
line, _, _ := strings.Cut(string(data), "\n")
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if line == "" {
|
||||
return "", fmt.Errorf("password file %s is empty", pwFile)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
||||
func seedDataDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
return dataDir
|
||||
}
|
||||
|
||||
// An encrypted backup is only worth having if the container it came from can
|
||||
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||
// the CLI are tested as the one round trip an operator actually performs.
|
||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
plain := filepath.Join(dir, "backup.tar.gz")
|
||||
const password = "a long enough password"
|
||||
|
||||
if err := run(encrypted, password); err != nil {
|
||||
t.Fatalf("create encrypted backup: %v", err)
|
||||
}
|
||||
head, err := os.ReadFile(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("read backup: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(head), "SELFPOST") {
|
||||
t.Fatalf("encrypted backup does not start with the envelope magic")
|
||||
}
|
||||
|
||||
if err := runDecrypt(encrypted, plain, "the wrong password"); err == nil {
|
||||
t.Fatal("decryption with the wrong password succeeded")
|
||||
}
|
||||
if err := runDecrypt(encrypted, plain, password); err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
|
||||
// What comes out must be the same gzip tar the plain path produces.
|
||||
f, err := os.Open(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("open decrypted archive: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar: %v", err)
|
||||
}
|
||||
names[hdr.Name] = true
|
||||
}
|
||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
||||
if !names[want] {
|
||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a password the CLI keeps producing the plain archive that existing
|
||||
// backup scripts consume.
|
||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
f, err := os.Open(out)
|
||||
if err != nil {
|
||||
t.Fatalf("open archive: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := gzip.NewReader(f); err != nil {
|
||||
t.Fatalf("plain backup is not a gzip archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypting needs a password, and it must come from a file or the environment
|
||||
// — never an argument, which the process list would expose.
|
||||
func TestReadPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pwFile := filepath.Join(dir, "pw")
|
||||
if err := os.WriteFile(pwFile, []byte("from the file\nignored second line\n"), 0o600); err != nil {
|
||||
t.Fatalf("write password file: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(passwordEnv, "from the environment")
|
||||
got, err := readPassword("")
|
||||
if err != nil || got != "from the environment" {
|
||||
t.Errorf("readPassword(\"\") = %q, %v", got, err)
|
||||
}
|
||||
got, err = readPassword(pwFile)
|
||||
if err != nil || got != "from the file" {
|
||||
t.Errorf("readPassword(file) = %q, %v", got, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pwFile, nil, 0o600); err != nil {
|
||||
t.Fatalf("truncate password file: %v", err)
|
||||
}
|
||||
if _, err := readPassword(pwFile); err == nil {
|
||||
t.Error("an empty password file was accepted")
|
||||
}
|
||||
|
||||
os.Unsetenv(passwordEnv)
|
||||
if err := runDecrypt("", "", ""); err == nil {
|
||||
t.Error("-decrypt without a password was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copy to .env next to docker-compose.yml and fill in.
|
||||
# Full reference: docs/guide.md "Environment variables".
|
||||
|
||||
# Required — the container refuses to start without it. Hostname used both for
|
||||
# the panel's TLS vhost and for Postfix's SASL realm (SASL_REALM defaults to
|
||||
# this) and myhostname; must also match your PTR record. Must match the
|
||||
# certificate your reverse proxy obtains. Bare FQDN only — no scheme or port.
|
||||
SELFPOST_HOSTNAME=mail.example.com
|
||||
|
||||
# Set to true to also enable RFC 6409 submission (587/STARTTLS) alongside the
|
||||
# primary 465/smtps listener.
|
||||
SUBMISSION_ENABLE=false
|
||||
|
||||
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||
|
||||
# Days of send-log history kept before the background sweep deletes rows.
|
||||
SEND_LOG_RETENTION_DAYS=90
|
||||
|
||||
# Sliding idle timeout for the panel login session, in days. No absolute cap:
|
||||
# an admin who keeps coming back stays signed in indefinitely. Polling by the
|
||||
# monitoring screens does not count as activity, only navigation/actions do.
|
||||
PANEL_SESSION_IDLE_DAYS=7
|
||||
|
||||
# Comma-separated resolvers the panel's deliverability checks (PTR, SPF, DKIM,
|
||||
# DMARC) query directly. They deliberately skip this host's own resolver: with
|
||||
# systemd-resolved the reverse lookup of the server's IP answers with the local
|
||||
# hostname instead of the PTR record published in public DNS, which made the
|
||||
# panel report a correct PTR as wrong. Leave unset for the public defaults
|
||||
# (1.1.1.1, 8.8.8.8, 9.9.9.9); set it if outbound port 53 is closed or you run
|
||||
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
||||
# SELFPOST_DNS_RESOLVERS=
|
||||
|
||||
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||
# you know the exact address of your reverse proxy — trusting the wrong
|
||||
# source lets a client spoof its rate-limit key. Behind the default Apache
|
||||
# host-network setup this is typically the Docker bridge gateway, e.g.
|
||||
# TRUSTED_PROXY_CIDR=172.18.0.1
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
||||
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
||||
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
|
||||
# (`a2enmod ssl proxy proxy_http`).
|
||||
#
|
||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
||||
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
|
||||
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
|
||||
# The resulting PEM files land at
|
||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
|
||||
# — bind-mount that directory read-only into the SelfPost container (see
|
||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||
# can present the very same certificate on 465/587. One certificate, two
|
||||
# consumers (spec 10 p.4) — no separate cert just for the panel.
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName mail.example.com
|
||||
|
||||
# Filled in by `certbot --apache`; shown here for clarity.
|
||||
# SSLEngine on
|
||||
# SSLCertificateFile /etc/letsencrypt/live/mail.example.com/fullchain.pem
|
||||
# SSLCertificateKeyFile /etc/letsencrypt/live/mail.example.com/privkey.pem
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||
|
||||
# The panel is plain HTTP behind the proxy; it never terminates TLS
|
||||
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
||||
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName mail.example.com
|
||||
# certbot's http-01 challenge needs this on plain :80; everything else
|
||||
# redirects to HTTPS once certbot adds the RewriteRule/Redirect block.
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
|
||||
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
@@ -0,0 +1,3 @@
|
||||
mail.example.com {
|
||||
reverse_proxy selfpost:8080
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
||||
# automatic ACME with no separate certbot container. Caddy writes certificates
|
||||
# as plain PEM under its own data directory, which this fragment bind-mounts
|
||||
# to the host so SelfPost can read the same files.
|
||||
#
|
||||
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
|
||||
# the ACME CA's name as a path segment, e.g.
|
||||
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||
# This has been stable across recent Caddy releases but is an internal
|
||||
# storage detail, not a documented public API — VERIFY the exact path against
|
||||
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
|
||||
# before wiring it into the volume mount below.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
environment:
|
||||
# Caddy names certificate files after the hostname, not
|
||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||
# paths to match.
|
||||
TLS_CERT_FILE: /etc/postfix/tls/<hostname>.crt
|
||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- selfpost
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./caddy/caddy-data:/data
|
||||
- ./caddy/caddy-config:/config
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
||||
#
|
||||
# This file only runs SelfPost itself. Apache is assumed to already be
|
||||
# installed on the HOST (the target audience for this project typically runs
|
||||
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
|
||||
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
|
||||
# vhost fragment, and docs/guide.md "Reverse proxy" for the
|
||||
# certbot steps that produce the PEM files this compose file mounts.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||
# ./certs directories, or adjust the paths below.
|
||||
# 2. Fill in .env (at minimum SELFPOST_HOSTNAME). Put PEM files in ./certs —
|
||||
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
|
||||
# restore compares the manifest version against the running binary's version,
|
||||
# so ":latest" would make that check meaningless. Bump the tag deliberately
|
||||
# when you want to upgrade.
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:1.0.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
# Path Postfix reads inside the container — matches the certs bind mount
|
||||
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
|
||||
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||
# alongside the primary 465/smtps listener (spec 5).
|
||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||
# limits (level 2) are configured later from the panel itself.
|
||||
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||
# How long the send log keeps rows before the background sweep deletes
|
||||
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||
# report what the internet sees rather than what this host's own stub
|
||||
# resolver synthesises. Empty uses public defaults.
|
||||
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
||||
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
||||
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
||||
# client spoof its rate-limit key.
|
||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||
volumes:
|
||||
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
||||
# sasldb2, sender map, setup token. Back this up (panel button or the
|
||||
# selfpost-backup CLI) before you touch it directly.
|
||||
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
||||
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||
# a container recreate; it is diagnostics and is left out of backups.
|
||||
- ./data:/data
|
||||
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||
- ./certs:/etc/postfix/tls:ro
|
||||
ports:
|
||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||
# published directly — mail traffic bypasses Apache entirely, it only
|
||||
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
||||
# (8080) is intentionally NOT published here: Apache reaches it over
|
||||
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
||||
# panel is never directly reachable from the internet without TLS.
|
||||
- "465:465"
|
||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||
# when the variable is true — see docs/guide.md "Published ports".
|
||||
- "587:587"
|
||||
- "127.0.0.1:8080:8080"
|
||||
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||
# root very briefly to fix /data ownership and normalise permissions
|
||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||
# self-healing. What IS applied: no privilege escalation past what the
|
||||
# image already grants, and every Linux capability dropped except the
|
||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||
# dirs/files while still root (owner-check bypass);
|
||||
# - FSETID — set the setgid bit (2750) on the shared /data dirs
|
||||
# when the process gid differs from the dir's group;
|
||||
# - SETUID/SETGID — supervisord drops the panel to the unprivileged
|
||||
# `panel` user; Postfix switches to its own users;
|
||||
# - DAC_OVERRIDE — cross-user file access within the `selfpost` group.
|
||||
# - KILL — supervisord (root) sends SIGUSR1 to opendkim (a
|
||||
# different uid) on domain add/remove to reload its
|
||||
# KeyTable/SigningTable; without it os.kill() fails
|
||||
# with EPERM even though the caller is root, because
|
||||
# the kernel's signal permission check for
|
||||
# cross-uid kill() still consults CAP_KILL.
|
||||
# FOWNER/FSETID are required by build/entrypoint.sh's permission
|
||||
# self-healing; without them chmod fails with EPERM and the container
|
||||
# crash-loops on start.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- FSETID
|
||||
- SETUID
|
||||
- SETGID
|
||||
- DAC_OVERRIDE
|
||||
- KILL
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
||||
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
||||
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
||||
#
|
||||
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
|
||||
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
|
||||
# takes over 80/443 and proxies to the panel over the compose network
|
||||
# instead). Merge it with the base file — run this from the deploy/ directory
|
||||
# (Compose resolves every relative path in both files against the directory of
|
||||
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
||||
#
|
||||
# First-run certificate issuance (webroot method, before nginx has a cert to
|
||||
# serve — run once):
|
||||
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
||||
# run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
# No host publish for 8080 here: nginx reaches it over the compose
|
||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
# Same host directory certbot below writes into — plain bind mount,
|
||||
# no named volume, so the PEM files are as directly inspectable as in
|
||||
# the Apache scenario (spec 10.3).
|
||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- selfpost
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf.example:/etc/nginx/conf.d/selfpost.conf:ro
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt:ro
|
||||
- ./nginx/certbot-www:/var/www/certbot:ro
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
volumes:
|
||||
- ./nginx/certbot-etc:/etc/letsencrypt
|
||||
- ./nginx/certbot-www:/var/www/certbot
|
||||
# Renewal twice a day is certbot's own recommended cadence; it no-ops
|
||||
# until a certificate is within its renewal window.
|
||||
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
|
||||
@@ -0,0 +1,32 @@
|
||||
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
||||
# over the compose network; mail (465/587) is published directly by the
|
||||
# selfpost container and never touches nginx.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name mail.example.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name mail.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://selfpost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
||||
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
||||
# needs separate PEM files for Postfix, so an extraction step is required; see
|
||||
# extract-cert.sh in this directory and the cron/systemd-timer note below.
|
||||
#
|
||||
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||
# resolves every relative path in both files against the directory of the
|
||||
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
ports: !override
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
volumes: !override
|
||||
- ./data:/data
|
||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||
labels:
|
||||
# Traefik only proxies the panel; it never sees the mail ports.
|
||||
# These live on the selfpost service (not traefik's) because the
|
||||
# docker provider reads routing labels off the container being routed
|
||||
# to, not off traefik itself.
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
|
||||
- traefik.http.routers.selfpost.entrypoints=websecure
|
||||
- traefik.http.routers.selfpost.tls.certresolver=le
|
||||
- traefik.http.services.selfpost.loadbalancer.server.port=8080
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- selfpost
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --certificatesresolvers.le.acme.email=you@example.com
|
||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik/letsencrypt:/letsencrypt
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
||||
# is not itself watched by SelfPost/Postfix.
|
||||
#
|
||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||
set -eu
|
||||
|
||||
ACME_JSON="${1:?path to acme.json}"
|
||||
DOMAIN="${2:?domain name, e.g. mail.example.com}"
|
||||
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
|
||||
|
||||
jq -r --arg domain "$DOMAIN" '
|
||||
.le.Certificates[]
|
||||
| select(.domain.main == $domain)
|
||||
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
|
||||
|
||||
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
|
||||
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
|
||||
@@ -0,0 +1,310 @@
|
||||
# SelfPost — architecture (as-built)
|
||||
|
||||
**Source of truth:** the code tree, not historical specs. Synchronise this file
|
||||
when env keys, routes, or mail-path behaviour change. Verification method:
|
||||
[development.md](development.md) § «Verifying docs against code».
|
||||
|
||||
User install/operations: [README.md](../README.md), [guide.md](guide.md). Product boundaries:
|
||||
[product.md](product.md).
|
||||
|
||||
---
|
||||
|
||||
## Image and processes
|
||||
|
||||
Single Debian slim image. `entrypoint.sh` (root) fixes `/data` ownership and
|
||||
milter socket directories, **requires** `SELFPOST_HOSTNAME` (FQDN with at least
|
||||
one dot; no scheme, port, or spaces — invalid or empty value → `exit 1` before
|
||||
Postfix config or supervisord), then execs `supervisord` as PID 1.
|
||||
|
||||
The hostname is not a tunable default: it must match PTR/rDNS, TLS CN/SAN, and
|
||||
SASL realm together. Soft fallbacks (`localhost` in the panel vs container ID in
|
||||
Postfix) split realms and break SMTP AUTH with a silent `535` in clients while
|
||||
the panel looks healthy. Shipped `docker-compose.yml` already requires the
|
||||
variable; the entrypoint gate catches `docker run`, custom compose, and k8s.
|
||||
|
||||
Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
|
||||
| Program | User | Priority | Role |
|
||||
|---|---|---|---|
|
||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
|
||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||
|
||||
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
|
||||
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
|
||||
`postfix start-fg`.
|
||||
|
||||
`crashexit` event listener exits the container on any managed process FATAL so
|
||||
Docker `restart: unless-stopped` recreates a broken instance.
|
||||
|
||||
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
|
||||
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
|
||||
|
||||
---
|
||||
|
||||
## Mail path
|
||||
|
||||
```
|
||||
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
||||
│
|
||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||
└─► outbound MX delivery (port 25 client)
|
||||
```
|
||||
|
||||
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||
|
||||
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||
`smtpd_tls_security_level=encrypt`.
|
||||
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
|
||||
- **Chroot disabled** for all services (DNS/TLS inside container).
|
||||
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
|
||||
reload via `cert-reload`.
|
||||
|
||||
### OpenDKIM
|
||||
|
||||
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
||||
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
||||
|
||||
### Panel binary ([cmd/panel](../cmd/panel))
|
||||
|
||||
One process, three roles:
|
||||
|
||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||
proxy only.
|
||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||
(`default_action=accept`) so milter failure does not stop mail. The level-2
|
||||
count is the stored send-log rows plus the messages this process has admitted
|
||||
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
|
||||
cannot each spend the same last slot; a reservation is released at
|
||||
end-of-message, on ABORT, or after a 10-minute TTL.
|
||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
|
||||
### Log tailer and `mail.log` rotation
|
||||
|
||||
`mail.log` lives at `/data/log/mail.log` — inside the persistent bind mount, so
|
||||
the delivery lines that resolve a `queued` send-log row are not lost when the
|
||||
container is recreated. `postlogd` writes it as user `postfix`; the panel reads
|
||||
it through the shared `selfpost` group (directory `2750 postfix:selfpost`, file
|
||||
`0640`, both normalised on every start by
|
||||
[build/entrypoint.sh](../build/entrypoint.sh)). The path is one default in two
|
||||
places, `maillog_file` in [build/postfix-config.sh](../build/postfix-config.sh)
|
||||
and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude
|
||||
`log/`: it is diagnostic output, not state to restore.
|
||||
|
||||
Rotation uses rename + `postfix reload`
|
||||
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` —
|
||||
the latter can drop `status=sent` lines and leave send-log rows stuck at
|
||||
`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd
|
||||
recreates the file lazily on first write as mode `0600`, which the unprivileged
|
||||
panel user cannot read). `follow()` drains the old inode once more before
|
||||
switching descriptors; the panel treats a missing log file as an empty tail, not
|
||||
an error.
|
||||
|
||||
**Read offset is persisted** (`logtail_state` table, migration `0003`): the
|
||||
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
|
||||
on start resumes from it, parsing the tail written while the panel was down. If
|
||||
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
|
||||
the current file from the start; re-parsing lines is harmless because
|
||||
`UpdateStatus` writes the same status onto the same row. Only a first-ever start,
|
||||
with nothing stored, begins at end-of-file, so installing the panel does not
|
||||
replay a pre-existing log.
|
||||
|
||||
**Queue reconcile** is the backstop for what the log cannot explain at all: a
|
||||
row still `queued` more than two minutes after it was accepted, whose queue id
|
||||
`postqueue -p` no longer lists, is marked `bounced` (swept every five minutes,
|
||||
[internal/logtail](../internal/logtail/logtail.go),
|
||||
[postfix.QueueIDs](../internal/postfix/queue.go)). Postfix having dropped the
|
||||
message means nothing more will ever be reported about it, so the row can only
|
||||
be closed on an assumption, and it is closed as a failure because a delivery the
|
||||
panel cannot evidence must not be shown as one. Three things keep the sweep from
|
||||
guessing where it need not: it starts only after the tailer has read to
|
||||
end-of-file once (on a restart the log itself holds the answer), the two-minute
|
||||
grace covers messages merely in flight, and a `postqueue` that cannot be read
|
||||
leaves every row untouched rather than closing them all. Now that the log
|
||||
survives the container, reaching this path means the lines are gone for good —
|
||||
rotated past fourteen files while the panel was down, or deleted.
|
||||
|
||||
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both
|
||||
serving panel pages on request: `TailLines` (the last *n* lines, for
|
||||
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
|
||||
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
|
||||
finding a message's lines means reading rather than seeking — and matches the id
|
||||
anchored on the character before it, since queue ids are hexadecimal runs and a
|
||||
shorter one is regularly the tail of a longer one. Send-log rows outlive the log
|
||||
(retention 90 days, rotation 14 files), so an empty result is the expected end
|
||||
state for an older message and the page reports it as such, not as a failure.
|
||||
|
||||
---
|
||||
|
||||
## Panel HTTP surface
|
||||
|
||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
||||
unless noted. The table below is a summary — HTMX fragment endpoints
|
||||
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
|
||||
`/system-log/body`, …) and every POST variant live in `web.go`.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `/healthz` | Liveness (no auth) |
|
||||
| `/setup/*` | One-time admin bootstrap |
|
||||
| `/login`, `/logout` | Session auth |
|
||||
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
|
||||
| `/domains/import` | Domain import (`POST`; form on the Backup page) |
|
||||
| `/deliveries` | Send log with filters |
|
||||
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines |
|
||||
| `/mail-queue` | Postfix queue view |
|
||||
| `/system-log` | `mail.log` tail |
|
||||
| `/reload` | Reload OpenDKIM + Postfix maps |
|
||||
| `/backup` | Full backup download (page also hosts the import form) |
|
||||
| `/account` | Admin username/password |
|
||||
|
||||
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
||||
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||
activity).
|
||||
|
||||
### Sessions
|
||||
|
||||
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
|
||||
token; the database stores **SHA-256 of the token**, not the token itself — a
|
||||
stolen DB or backup archive does not alone grant login, but a browser that still
|
||||
holds the cookie works after process restart, redeploy, or full backup restore.
|
||||
|
||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||
absolute cap (regular use keeps the session alive indefinitely).
|
||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
|
||||
- **Password change** — all other sessions are deleted; the current session stays
|
||||
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
||||
[handlers_account.go](../internal/web/handlers_account.go)).
|
||||
|
||||
Restoring an **older** backup also restores session rows: a session invalidated
|
||||
after that backup was taken can become valid again if the browser still has the
|
||||
cookie and idle timeout has not expired.
|
||||
|
||||
---
|
||||
|
||||
## Code layers
|
||||
|
||||
Multi-store writes that must land in more than one place (SQLite row,
|
||||
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
|
||||
also where the rollback of a partial failure lives. Handlers may call
|
||||
`store` directly for single-table reads and simple writes (sessions, admin,
|
||||
send-log queries); the first-run setup-token file is read and written in
|
||||
`web` itself. The adapters below the services are the only code that knows
|
||||
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
|
||||
substitutable in tests — `milter.Store`, `app.SenderMaps` and
|
||||
`logtail.StatusStore` are the seams the unit tests replace with fakes.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph cmd ["cmd — composition root"]
|
||||
panel["panel: HTTP + journal-milter + log-tailer"]
|
||||
backupcli["selfpost-backup CLI"]
|
||||
end
|
||||
subgraph web ["internal/web — HTTP surface"]
|
||||
handlers["handlers_*.go, templates, session/security"]
|
||||
end
|
||||
subgraph services ["Services — multi-store operations + rollback"]
|
||||
domainSvc["internal/domain"]
|
||||
appSvc["internal/app"]
|
||||
end
|
||||
subgraph persistence ["Persistence"]
|
||||
store["internal/store — SQLite, embedded migrations"]
|
||||
end
|
||||
subgraph adapters ["Adapters — the only infrastructure-aware code"]
|
||||
postfix["internal/postfix"]
|
||||
milterPkg["internal/milter"]
|
||||
logtail["internal/logtail"]
|
||||
dnscheck["internal/dnscheck"]
|
||||
backupPkg["internal/backup"]
|
||||
health["internal/health"]
|
||||
secretfile["internal/secretfile"]
|
||||
end
|
||||
panel --> web
|
||||
panel --> milterPkg
|
||||
panel --> logtail
|
||||
backupcli --> backupPkg
|
||||
backupcli --> secretfile
|
||||
web --> store
|
||||
web --> domainSvc
|
||||
web --> appSvc
|
||||
web --> backupPkg
|
||||
web --> dnscheck
|
||||
web --> health
|
||||
web --> secretfile
|
||||
domainSvc --> store
|
||||
appSvc --> store
|
||||
milterPkg --> store
|
||||
logtail --> store
|
||||
domainSvc --> postfix
|
||||
appSvc --> postfix
|
||||
```
|
||||
|
||||
The three roles inside the `panel` process (HTTP server, journal-milter,
|
||||
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
|
||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||
single-connection trade-off that follows from it.
|
||||
|
||||
---
|
||||
|
||||
## Persistence (`/data` bind mount)
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
||||
| `setup-token` | First-run setup token file |
|
||||
| `opendkim/` | DKIM keys + tables |
|
||||
| `sasl/sasldb2` | Application SASL credentials |
|
||||
| `postfix/sender_login_maps` | Login → From binding |
|
||||
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||
|
||||
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
||||
(transit mail not migrated by design).
|
||||
|
||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||
|
||||
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
|
||||
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
||||
restore. Stopped-container `tar` of `./data` is safe (see guide).
|
||||
|
||||
**Optional encryption** of the two secret-bearing downloads
|
||||
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
|
||||
its counter and an end-of-stream flag (so truncation and reordering fail to
|
||||
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
|
||||
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
|
||||
default. Domain import detects the envelope by magic bytes; an encrypted full
|
||||
backup is converted back with `selfpost-backup -decrypt` before restore.
|
||||
|
||||
---
|
||||
|
||||
## Security (summary)
|
||||
|
||||
Mandatory checklist: [security.md](security.md). Accepted trade-offs (CSRF
|
||||
origin check, no CSRF tokens) are documented there separately.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Public and internal env vars: [guide § Environment variables](guide.md#environment-variables).
|
||||
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 854 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||
file needs no font installed to render as drawn.
|
||||
This is the mark's small-size variant, and its S is Medium where the
|
||||
wordmark's is ExtraLight. At the wordmark's weights the S stem is
|
||||
0.90 against the P's 3.40, which at 16px is a quarter of a pixel
|
||||
against most of one: the pair rasterised to a P with a smudge beside
|
||||
it. Medium costs the Self/Post weight play, which needs more pixels
|
||||
than this variant exists to work in, and keeps both letters. -->
|
||||
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||
<path d="M24.55 43.31Q22.29 43.31 20.68 42.47Q19.06 41.62 17.92 40.24L19.97 38.35Q20.91 39.52 22.07 40.11Q23.22 40.71 24.71 40.71Q26.45 40.71 27.33 39.92Q28.22 39.13 28.22 37.83Q28.22 37.12 27.96 36.6Q27.7 36.08 27.07 35.75Q26.45 35.41 25.41 35.2L23.8 34.91Q22.03 34.58 20.84 33.93Q19.66 33.28 19.06 32.22Q18.47 31.17 18.47 29.74Q18.47 28.13 19.25 26.96Q20.03 25.79 21.47 25.16Q22.91 24.54 24.84 24.54Q26.89 24.54 28.4 25.26Q29.91 25.97 30.95 27.32L28.89 29.14Q28.19 28.23 27.18 27.69Q26.16 27.14 24.65 27.14Q23.09 27.14 22.25 27.76Q21.4 28.39 21.4 29.58Q21.4 30.34 21.72 30.83Q22.03 31.33 22.66 31.64Q23.3 31.95 24.26 32.13L25.88 32.47Q27.7 32.81 28.87 33.48Q30.04 34.16 30.59 35.19Q31.15 36.21 31.15 37.67Q31.15 39.36 30.37 40.62Q29.59 41.88 28.11 42.6Q26.63 43.31 24.55 43.31Z M37.02 43H33.59V24.85H41.75Q43.44 24.85 44.64 25.55Q45.83 26.26 46.48 27.52Q47.13 28.78 47.13 30.47Q47.13 32.16 46.48 33.42Q45.83 34.68 44.64 35.38Q43.44 36.08 41.75 36.08H37.02ZM37.02 27.84V33.12H41.44Q42.11 33.12 42.58 32.87Q43.05 32.63 43.31 32.17Q43.57 31.72 43.57 31.07V29.87Q43.57 29.19 43.31 28.75Q43.05 28.31 42.58 28.08Q42.11 27.84 41.44 27.84Z" fill="#F6EDE2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||
file needs no font installed to render as drawn. -->
|
||||
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round" />
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E" />
|
||||
<path d="M25.21 31.17Q24.01 31.17 23.22 30.71Q22.44 30.26 21.89 29.54L22.29 29.24Q22.8 29.91 23.48 30.31Q24.16 30.71 25.23 30.71Q26.44 30.71 27.11 30.13Q27.77 29.56 27.77 28.48Q27.77 27.74 27.49 27.31Q27.21 26.88 26.72 26.66Q26.23 26.44 25.63 26.31L24.69 26.13Q23.73 25.95 23.17 25.57Q22.62 25.2 22.38 24.69Q22.13 24.18 22.13 23.58Q22.13 22.73 22.51 22.17Q22.89 21.61 23.57 21.33Q24.26 21.06 25.17 21.06Q26.26 21.06 26.99 21.44Q27.72 21.82 28.14 22.45L27.75 22.74Q27.33 22.14 26.7 21.83Q26.07 21.52 25.14 21.52Q23.98 21.52 23.31 22.03Q22.65 22.53 22.65 23.57Q22.65 24.25 22.94 24.66Q23.22 25.08 23.71 25.3Q24.2 25.53 24.81 25.65L25.74 25.83Q26.74 26.02 27.29 26.4Q27.84 26.79 28.06 27.31Q28.28 27.84 28.28 28.44Q28.28 29.33 27.91 29.94Q27.54 30.54 26.84 30.85Q26.15 31.17 25.21 31.17Z M32.51 31.17Q31.57 31.17 30.89 30.73Q30.22 30.3 29.84 29.47Q29.47 28.65 29.47 27.44Q29.47 26.24 29.84 25.41Q30.2 24.57 30.87 24.15Q31.53 23.72 32.47 23.72Q33.34 23.72 34 24.15Q34.65 24.59 35.02 25.38Q35.4 26.17 35.4 27.25V27.47H29.99V27.85Q29.99 29.15 30.66 29.94Q31.32 30.73 32.53 30.73Q33.37 30.73 33.96 30.32Q34.56 29.91 34.86 29.11L35.24 29.32Q34.93 30.16 34.23 30.66Q33.53 31.17 32.51 31.17ZM32.47 24.14Q31.69 24.14 31.13 24.5Q30.58 24.85 30.29 25.5Q29.99 26.16 29.99 27.02V27.08H34.86V27Q34.86 26.13 34.56 25.49Q34.26 24.85 33.72 24.5Q33.18 24.14 32.47 24.14Z M38.85 31H37.8Q37.49 31 37.34 30.85Q37.19 30.69 37.19 30.41V20.64H37.68V30.57H38.85Z M40.67 31V24.32H39.4V23.89H40.67V21.35Q40.67 21.02 40.84 20.83Q41.01 20.64 41.36 20.64H42.76V21.07H41.16V23.89H42.76V24.32H41.16V31Z" fill="#F6EDE2" />
|
||||
<path d="M21.26 45H19.41V35.23H23.81Q24.72 35.23 25.36 35.61Q26 35.98 26.35 36.66Q26.7 37.34 26.7 38.25Q26.7 39.16 26.35 39.84Q26 40.52 25.36 40.9Q24.72 41.28 23.81 41.28H21.26ZM21.26 36.84V39.68H23.64Q24 39.68 24.25 39.55Q24.51 39.41 24.65 39.17Q24.79 38.92 24.79 38.57V37.93Q24.79 37.57 24.65 37.33Q24.51 37.09 24.25 36.96Q24 36.84 23.64 36.84Z M30.77 45.17Q29.73 45.17 28.97 44.7Q28.21 44.23 27.79 43.37Q27.38 42.51 27.38 41.33Q27.38 40.16 27.79 39.31Q28.21 38.46 28.97 37.99Q29.73 37.52 30.77 37.52Q31.82 37.52 32.58 37.99Q33.34 38.46 33.76 39.31Q34.17 40.16 34.17 41.33Q34.17 42.51 33.76 43.37Q33.34 44.23 32.58 44.7Q31.82 45.17 30.77 45.17ZM30.77 43.73Q31.48 43.73 31.89 43.28Q32.3 42.84 32.3 42.02V40.66Q32.3 39.83 31.89 39.4Q31.48 38.97 30.77 38.97Q30.08 38.97 29.67 39.4Q29.26 39.83 29.26 40.66V42.02Q29.26 42.84 29.67 43.28Q30.08 43.73 30.77 43.73Z M37.75 45.17Q36.7 45.17 35.98 44.81Q35.27 44.45 34.72 43.82L35.81 42.76Q36.22 43.22 36.7 43.49Q37.19 43.75 37.82 43.75Q38.46 43.75 38.75 43.53Q39.03 43.31 39.03 42.91Q39.03 42.59 38.83 42.4Q38.63 42.21 38.14 42.14L37.41 42.05Q36.63 41.95 36.08 41.68Q35.53 41.42 35.25 40.95Q34.98 40.49 34.98 39.83Q34.98 38.77 35.74 38.15Q36.5 37.52 37.75 37.52Q38.46 37.52 38.97 37.66Q39.48 37.79 39.88 38.05Q40.27 38.31 40.6 38.67L39.55 39.72Q39.23 39.39 38.78 39.16Q38.34 38.94 37.8 38.94Q37.24 38.94 36.97 39.14Q36.7 39.34 36.7 39.69Q36.7 40.06 36.91 40.25Q37.13 40.44 37.65 40.52L38.39 40.62Q39.59 40.79 40.18 41.33Q40.76 41.86 40.76 42.77Q40.76 43.47 40.39 44.02Q40.02 44.57 39.34 44.87Q38.66 45.17 37.75 45.17Z M45.51 45H44.22Q43.3 45 42.81 44.52Q42.33 44.03 42.33 43.14V39.11H41.25V37.69H41.81Q42.22 37.69 42.37 37.5Q42.51 37.31 42.51 36.94V35.69H44.12V37.69H45.62V39.11H44.12V43.57H45.51Z" fill="#F6EDE2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SelfPost — знак на утверждение</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--ink:#12161C;--paper:#F7F6F4;--brick:#7A3B2E;--slate:#6B7684;--line:#DEDCD7}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:'IBM Plex Sans',system-ui,sans-serif;font-weight:300;-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:56px 24px 96px}
|
||||
header{border-bottom:2px solid var(--brick);padding-bottom:28px}
|
||||
.eyebrow{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--brick);margin:0 0 14px}
|
||||
h1{font-size:clamp(28px,5vw,40px);font-weight:200;letter-spacing:-.02em;margin:0 0 10px}
|
||||
h1 b{font-weight:600}
|
||||
header p{margin:0;max-width:62ch;color:#3C444F;line-height:1.55;font-size:15px}
|
||||
section{border-bottom:1px solid var(--line);padding:40px 0}
|
||||
h2{font-size:13px;font-weight:500;margin:0 0 20px;letter-spacing:.1em;text-transform:uppercase;font-family:'IBM Plex Mono',monospace;color:var(--slate)}
|
||||
.hero{background:#fff;border:1px solid var(--line);border-radius:3px;padding:44px;display:flex;justify-content:center}
|
||||
.hero svg{width:100%;max-width:430px;height:auto}
|
||||
.swatches{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
|
||||
.sw{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border:1px solid var(--line);border-radius:2px;padding:6px 10px;display:flex;align-items:center;gap:7px;background:#fff}
|
||||
.sw i{width:14px;height:14px;border:1px solid rgba(0,0,0,.12);display:block}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
|
||||
.cap{font-size:12.5px;line-height:1.5;color:#5A6470;margin-top:8px}
|
||||
.cap b{font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:var(--ink)}
|
||||
|
||||
/* mock: github */
|
||||
.gh{border:1px solid #D1D9E0;border-radius:6px;padding:18px;background:#fff;font-size:13px}
|
||||
.gh.dk{background:#0D1117;border-color:#30363D;color:#E6EDF3}
|
||||
.gh .bc{font-size:14px;color:#0969DA;margin-bottom:14px}
|
||||
.gh.dk .bc{color:#4493F8}
|
||||
.gh .bc b{font-weight:600}
|
||||
.gh .rd{border:1px solid #D1D9E0;border-radius:6px;padding:20px}
|
||||
.gh.dk .rd{border-color:#30363D}
|
||||
.gh .rd svg{width:280px;max-width:100%;height:auto;display:block;margin-bottom:16px}
|
||||
.gh h3{font-size:15px;font-weight:600;margin:0 0 8px;padding-bottom:6px;border-bottom:1px solid #D1D9E0}
|
||||
.gh.dk h3{border-color:#30363D}
|
||||
.gh p{margin:0;color:#59636E;line-height:1.6;font-size:12.5px}
|
||||
.gh.dk p{color:#9198A1}
|
||||
|
||||
/* mock: browser tab */
|
||||
.tabs{background:#DEE1E6;border-radius:8px 8px 0 0;padding:8px 8px 0;display:flex;gap:4px}
|
||||
.tab{background:#fff;border-radius:8px 8px 0 0;padding:8px 12px;display:flex;align-items:center;gap:7px;font-size:12px;color:#3C4043;max-width:190px}
|
||||
.tab.off{background:#C9CDD3;color:#5F6368}
|
||||
.tab svg{width:16px;height:16px;flex:none}
|
||||
.tab span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.url{background:#fff;padding:10px 12px;border-radius:0 0 8px 8px;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border-top:1px solid #DEE1E6}
|
||||
|
||||
/* mock: panel */
|
||||
.panel{border:1px solid var(--line);border-radius:4px;overflow:hidden;background:#fff}
|
||||
.ph{background:#12161C;padding:12px 16px;display:flex;align-items:center;gap:20px}
|
||||
.ph svg{height:34px;width:auto}
|
||||
.nav{display:flex;gap:16px;font-size:12.5px;color:#9AA3AE}
|
||||
.nav b{color:#fff;font-weight:500}
|
||||
.pb{padding:18px 16px;display:flex;flex-direction:column;gap:9px}
|
||||
.row{display:flex;align-items:center;gap:10px;font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#3C444F}
|
||||
.badge{font-size:10px;padding:2px 7px;border-radius:10px;font-weight:600;letter-spacing:.03em}
|
||||
.ok{background:#DCFCE7;color:#166534}
|
||||
.er{background:#FEE2E2;color:#991B1B}
|
||||
|
||||
/* avatars */
|
||||
.avs{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap}
|
||||
.av{border-radius:6px;overflow:hidden;border:1px solid var(--line);display:block}
|
||||
.sizes{display:flex;gap:22px;align-items:flex-end;flex-wrap:wrap;background:#fff;border:1px solid var(--line);border-radius:3px;padding:24px}
|
||||
.sizes .u{text-align:center}
|
||||
.sizes svg{display:block;margin:0 auto 8px}
|
||||
.sizes .u span{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--slate)}
|
||||
|
||||
.rules{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden}
|
||||
@media(max-width:760px){.rules{grid-template-columns:1fr}}
|
||||
.rule{background:#fff;padding:16px 18px}
|
||||
.rule b{display:block;font-weight:500;font-size:13.5px;margin-bottom:5px;color:var(--brick)}
|
||||
.rule span{font-size:12.5px;line-height:1.55;color:#5A6470}
|
||||
.files{font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:2;color:#3C444F}
|
||||
.files b{color:var(--ink);font-weight:600}
|
||||
.note{background:#F1EAE4;border-left:2px solid var(--brick);padding:14px 16px;font-size:13.5px;line-height:1.6;color:#3C444F;max-width:64ch;margin-top:20px}
|
||||
code{font-family:'IBM Plex Mono',monospace;font-size:12px;background:#EFEDE9;padding:1px 5px;border-radius:2px}
|
||||
</style></head>
|
||||
<body><div class="wrap">
|
||||
|
||||
<header>
|
||||
<p class="eyebrow">на утверждение · кирпич #7A3B2E</p>
|
||||
<h1>Self<b>Post</b></h1>
|
||||
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Эталон</h2>
|
||||
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg></div>
|
||||
<div class="swatches">
|
||||
<div class="sw"><i style="background:#7A3B2E"></i>#7A3B2E · печать</div>
|
||||
<div class="sw"><i style="background:#F3EDE1"></i>#F3EDE1 · поле</div>
|
||||
<div class="sw"><i style="background:#F6EDE2"></i>#F6EDE2 · краска по полю</div>
|
||||
<div class="sw"><i style="background:#12161C"></i>#12161C · контур 0.8</div>
|
||||
<div class="sw">контраст 7,3:1 · подзаголовок 4,6:1</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>README на GitHub</h2>
|
||||
<div class="grid2">
|
||||
<div><div class="gh">
|
||||
<div class="bc">mixeme / <b>selfpost</b></div>
|
||||
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg>
|
||||
<h3>SelfPost</h3>
|
||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
|
||||
<div><div class="gh dk">
|
||||
<div class="bc">mixeme / <b>selfpost</b></div>
|
||||
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg>
|
||||
<h3>SelfPost</h3>
|
||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
||||
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Шапка панели</h2>
|
||||
<div class="panel">
|
||||
<div class="ph"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
|
||||
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
|
||||
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
</svg>
|
||||
<div class="nav"><b>Domains</b><span>Applications</span><span>Send log</span><span>Backup</span></div></div>
|
||||
<div class="pb">
|
||||
<div class="row">example.org 14:22:07 <span class="badge ok">delivered</span></div>
|
||||
<div class="row">shop.example.org 14:21:55 <span class="badge er">bounced</span></div>
|
||||
<div class="row">example.org 14:19:03 <span class="badge ok">delivered</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cap"><b>проверка на конфликт</b><br>Ровно тот экран, ради которого мы отбраковывали краски: кирпич рядом с зелёным «delivered» и красным «bounced» не читается ни как один из них.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Фавикон и аватар</h2>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<div class="tabs">
|
||||
<div class="tab"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="500">S</tspan><tspan font-weight="600">P</tspan></text>
|
||||
</svg><span>SelfPost — Domains</span></div>
|
||||
<div class="tab off"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
|
||||
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
|
||||
</svg><span>docs</span></div>
|
||||
</div>
|
||||
<div class="url">https://mail.example.org/domains</div>
|
||||
<div class="cap"><b>16 px</b><br>Слева версия для мелкого размера: зубцы крупнее и реже, вместо двух строк — SP. Справа для сравнения полная иконка в том же размере: превращается в кашу.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="avs">
|
||||
<img class="av" width="80" height="80" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||
<img class="av" width="48" height="48" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
|
||||
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='500'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
|
||||
</div>
|
||||
<div class="cap"><b>80 / 48 / 32 px</b><br>Аватар организации на GitHub. Порог перехода на версию SP — 32 px.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Размеры и охранное поле</h2>
|
||||
<div class="sizes">
|
||||
<div class="u"><svg class="s1" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<rect x="-4" y="-12" width="338" height="174" fill="none" stroke="#7A3B2E" stroke-width="1" stroke-dasharray="4 3" opacity=".55"/><path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
|
||||
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
|
||||
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
|
||||
</svg><span>полная · от 280 px</span></div>
|
||||
<div class="u"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
|
||||
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
|
||||
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
|
||||
</svg><span>компактная · от 100 px</span></div>
|
||||
<div class="u"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
|
||||
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
|
||||
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
|
||||
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
|
||||
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
|
||||
</svg><span>иконка · до 100 px</span></div>
|
||||
</div>
|
||||
<div class="note"><b>Числа не на глаз.</b> Полная версия живёт до 280 px по ширине — ниже подзаголовок опускается под 6 px и перестаёт читаться. Компактная — до 100 px. Охранное поле (пунктир) равно <b>трём шагам зубцовки</b>, ≈ 21 % высоты марки: мера взята из самого знака, а не назначена.</div>
|
||||
<style>.s1{width:330px;max-width:100%;height:auto} .sizes .u:nth-child(2) svg{width:220px;max-width:100%;height:auto} .sizes .u:nth-child(3) svg{width:64px;height:64px}</style>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Чего не делать</h2>
|
||||
<div class="rules"><div class="rule"><b>Не растягивать</b><span>Пропорции 1:2,3 — часть знака: зубцовка рассчитана на них и при неравном масштабе зубцы становятся овалами.</span></div><div class="rule"><b>Не менять краску</b><span>Кирпич выбран по контрасту (7,3:1) и по тому, что он не спорит со статусами панели. Другой цвет ломает оба расчёта.</span></div><div class="rule"><b>Не класть на пёстрое</b><span>Волосяной контур рассчитан на ровный фон. На фото и градиенте край пропадает.</span></div><div class="rule"><b>Не добавлять рамок и теней</b><span>Марка держит край зубцовкой и контуром. Всё остальное — уже наклейка, а не марка.</span></div><div class="rule"><b>Не набирать подзаголовок мельче 6 px</b><span>Ниже полная версия перестаёт читаться — там уже компактная.</span></div></div>
|
||||
</section>
|
||||
|
||||
<section style="border-bottom:none">
|
||||
<h2>Файлы</h2>
|
||||
<div class="files">
|
||||
<b>docs/assets/selfpost-stamp.svg</b> — эталон, README, страницы входа панели<br>
|
||||
<b>docs/assets/selfpost-stamp-compact.svg</b> — шапка панели<br>
|
||||
<b>docs/assets/selfpost-icon.svg</b> — аватар, фавикон от 32 px<br>
|
||||
<b>docs/assets/selfpost-icon-16.svg</b> — фавикон 16–32 px
|
||||
</div>
|
||||
<div class="note"><b>Контуры сделаны.</b> Текст в четырёх файлах выше переведён в контуры по IBM Plex Sans (OFL) — шрифт на машине читателя больше не нужен, и <code>Self</code> с <code>Post</code> держат свои 200 и 600 везде. Панель отдаёт свои копии из <code>internal/web/static/</code>: <code>logo.svg</code>, <code>logo-compact.svg</code>, <code>favicon.svg</code> плюс растровый <code>favicon.png</code> 32×32 для браузеров без SVG-фавиконов — <code>go:embed</code> не дотягивается за пределы пакета, поэтому это копии, а не ссылки.<br>Макеты на этой странице остались с живым текстом: это протокол разбора знака, а не источник файлов.</div>
|
||||
</section>
|
||||
|
||||
</div></body></html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
|
||||
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
|
||||
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
|
||||
file needs no font installed to render as drawn. -->
|
||||
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round" />
|
||||
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E" />
|
||||
<path d="M61.06 66.41Q58.14 66.41 56.23 65.3Q54.33 64.2 53 62.46L53.96 61.72Q55.21 63.35 56.86 64.32Q58.51 65.29 61.1 65.29Q64.05 65.29 65.67 63.89Q67.28 62.5 67.28 59.88Q67.28 58.08 66.6 57.04Q65.92 56 64.73 55.46Q63.54 54.92 62.08 54.61L59.8 54.17Q57.46 53.73 56.12 52.83Q54.77 51.92 54.18 50.68Q53.58 49.44 53.58 47.98Q53.58 45.91 54.5 44.55Q55.42 43.19 57.08 42.52Q58.75 41.86 60.96 41.86Q63.61 41.86 65.38 42.78Q67.15 43.7 68.17 45.23L67.22 45.94Q66.2 44.48 64.67 43.73Q63.14 42.98 60.89 42.98Q58.07 42.98 56.46 44.21Q54.84 45.43 54.84 47.95Q54.84 49.61 55.54 50.61Q56.23 51.62 57.42 52.16Q58.61 52.71 60.08 53.01L62.35 53.45Q64.77 53.9 66.11 54.83Q67.45 55.77 67.98 57.04Q68.51 58.32 68.51 59.78Q68.51 61.95 67.61 63.42Q66.71 64.88 65.02 65.64Q63.34 66.41 61.06 66.41Z M78.56 66.41Q76.28 66.41 74.64 65.35Q72.99 64.3 72.09 62.29Q71.18 60.29 71.18 57.36Q71.18 54.44 72.07 52.42Q72.95 50.39 74.57 49.36Q76.18 48.32 78.46 48.32Q80.57 48.32 82.17 49.37Q83.76 50.43 84.67 52.35Q85.57 54.27 85.57 56.89V57.43H72.44V58.35Q72.44 61.51 74.06 63.43Q75.67 65.35 78.6 65.35Q80.64 65.35 82.08 64.35Q83.53 63.35 84.27 61.41L85.19 61.92Q84.44 63.96 82.74 65.18Q81.04 66.41 78.56 66.41ZM78.46 49.34Q76.56 49.34 75.21 50.21Q73.87 51.07 73.16 52.66Q72.44 54.24 72.44 56.34V56.48H84.27V56.28Q84.27 54.17 83.54 52.62Q82.81 51.07 81.5 50.21Q80.19 49.34 78.46 49.34Z M93.72 66H91.17Q90.42 66 90.06 65.63Q89.7 65.25 89.7 64.57V40.84H90.89V64.95H93.72Z M97.92 66V49.78H94.83V48.73H97.92V42.57Q97.92 41.76 98.33 41.3Q98.74 40.84 99.59 40.84H102.99V41.89H99.11V48.73H102.99V49.78H99.11V66Z M109.88 66H105.39V42.27H116.07Q118.28 42.27 119.84 43.19Q121.41 44.1 122.26 45.75Q123.11 47.4 123.11 49.61Q123.11 51.82 122.26 53.47Q121.41 55.12 119.84 56.04Q118.28 56.96 116.07 56.96H109.88ZM109.88 46.18V53.08H115.66Q116.55 53.08 117.16 52.76Q117.77 52.43 118.11 51.84Q118.45 51.24 118.45 50.39V48.83Q118.45 47.95 118.11 47.37Q117.77 46.79 117.16 46.48Q116.55 46.18 115.66 46.18Z M132.75 66.41Q130.24 66.41 128.38 65.27Q126.53 64.13 125.53 62.04Q124.53 59.95 124.53 57.09Q124.53 54.24 125.53 52.18Q126.53 50.12 128.38 48.98Q130.24 47.84 132.75 47.84Q135.3 47.84 137.16 48.98Q139.01 50.12 140.01 52.18Q141.02 54.24 141.02 57.09Q141.02 59.95 140.01 62.04Q139.01 64.13 137.16 65.27Q135.3 66.41 132.75 66.41ZM132.75 62.91Q134.49 62.91 135.47 61.83Q136.46 60.76 136.46 58.76V55.46Q136.46 53.45 135.47 52.4Q134.49 51.35 132.75 51.35Q131.09 51.35 130.08 52.4Q129.08 53.45 129.08 55.46V58.76Q129.08 60.76 130.08 61.83Q131.09 62.91 132.75 62.91Z M149.47 66.41Q146.92 66.41 145.19 65.54Q143.45 64.67 142.13 63.14L144.78 60.56Q145.77 61.68 146.94 62.33Q148.11 62.97 149.64 62.97Q151.21 62.97 151.9 62.43Q152.6 61.89 152.6 60.93Q152.6 60.15 152.11 59.69Q151.61 59.23 150.42 59.06L148.66 58.83Q146.75 58.59 145.43 57.94Q144.1 57.3 143.42 56.17Q142.74 55.05 142.74 53.45Q142.74 50.87 144.59 49.36Q146.45 47.84 149.47 47.84Q151.21 47.84 152.45 48.17Q153.69 48.49 154.64 49.12Q155.59 49.75 156.41 50.63L153.86 53.18Q153.08 52.37 151.99 51.82Q150.9 51.28 149.61 51.28Q148.25 51.28 147.58 51.77Q146.92 52.26 146.92 53.11Q146.92 54 147.45 54.46Q147.98 54.92 149.23 55.12L151.04 55.36Q153.96 55.77 155.37 57.08Q156.78 58.38 156.78 60.59Q156.78 62.29 155.88 63.62Q154.98 64.95 153.33 65.68Q151.68 66.41 149.47 66.41Z M168.09 66H164.97Q162.72 66 161.55 64.83Q160.38 63.65 160.38 61.48V51.69H157.76V48.25H159.12Q160.1 48.25 160.46 47.79Q160.82 47.33 160.82 46.42V43.39H164.73V48.25H168.37V51.69H164.73V62.53H168.09Z" fill="#F6EDE2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,328 @@
|
||||
# SelfPost — development
|
||||
|
||||
**What this file is.** How to build, test, document, and ship changes. Open
|
||||
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||
[architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Resuming work
|
||||
|
||||
After `/clear` or a fresh chat:
|
||||
|
||||
1. Read this file (process, docs rules, model routing).
|
||||
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||
as-built — [architecture.md](architecture.md).
|
||||
3. Skim [product.md](product.md) if scope is in doubt.
|
||||
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||
roadmap index).
|
||||
|
||||
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||
not duplicated here.
|
||||
|
||||
---
|
||||
|
||||
## Model routing
|
||||
|
||||
| Kind of work | Model | Examples |
|
||||
|---|---|---|
|
||||
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
|
||||
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
|
||||
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
|
||||
| Security **review** (not authorship) | **Fable** | pre-release checklist (CHANGELOG `[0.5.0]` Security / [security.md](security.md) — done) |
|
||||
|
||||
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||
|
||||
---
|
||||
|
||||
## Technology stack and tools
|
||||
|
||||
| Component | Version / notes |
|
||||
|---|---|
|
||||
| **Go** | 1.26+ (`go.mod`); `CGO_ENABLED=0` — pure Go, static linking |
|
||||
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) |
|
||||
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
|
||||
| **Container** | Docker + Compose v2 on the dev host and in CI |
|
||||
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) |
|
||||
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate |
|
||||
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
|
||||
| **Image registry** | `ghcr.io/mixeme/selfpost` |
|
||||
|
||||
**Repository layout** (brief; process details in [architecture.md](architecture.md)):
|
||||
|
||||
- `cmd/panel` — HTTP panel + journal-milter + log-tailer
|
||||
- `cmd/selfpost-backup` — backup CLI (`docker exec … selfpost-backup`)
|
||||
- `internal/` — domain logic, store, web, health
|
||||
- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM, entrypoint
|
||||
- `deploy/` — `docker-compose.yml`, proxy examples, `.env.example`
|
||||
- `test/e2e/` — **separate Go module**; container integration tests
|
||||
|
||||
---
|
||||
|
||||
## External libraries
|
||||
|
||||
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must
|
||||
be permissive or GPL-family (see
|
||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||
|
||||
### Main module (`go.mod`)
|
||||
|
||||
| Package | Version | Repository | License |
|
||||
|---|---|---|---|
|
||||
| `github.com/emersion/go-milter` | v0.4.1 | <https://github.com/emersion/go-milter> | BSD-2-Clause |
|
||||
| `golang.org/x/crypto` | v0.54.0 | <https://github.com/golang/crypto> | BSD-3-Clause |
|
||||
| `modernc.org/sqlite` | v1.53.0 | <https://gitlab.com/cznic/sqlite> (mirror: <https://github.com/modernc-org/sqlite>) | BSD-3-Clause |
|
||||
|
||||
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
||||
the tree are AGPL-3.0-compatible.
|
||||
|
||||
### E2e module (`test/e2e/go.mod`)
|
||||
|
||||
| Package | Version | Repository | License |
|
||||
|---|---|---|---|
|
||||
| `github.com/emersion/go-msgauth` | v0.6.8 | <https://github.com/emersion/go-msgauth> | BSD-2-Clause |
|
||||
|
||||
The test module is not part of the main `go build` graph and is not shipped in
|
||||
the image.
|
||||
|
||||
### Debian packages in the runtime image
|
||||
|
||||
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||
file on <https://packages.debian.org/bookworm/>.
|
||||
|
||||
---
|
||||
|
||||
## Building binaries and the image
|
||||
|
||||
### Local binaries
|
||||
|
||||
Requires Go 1.26+ and `CGO_ENABLED=0`.
|
||||
|
||||
```sh
|
||||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||
make build VERSION=1.0.0
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```sh
|
||||
go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel
|
||||
```
|
||||
|
||||
The version is stamped into both binaries via `-ldflags` and **must match the
|
||||
Docker image tag** — restore checks backup version compatibility.
|
||||
|
||||
### Docker image
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||
```
|
||||
|
||||
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
|
||||
runtime stage (Debian + mail stack). See [architecture.md](architecture.md) §
|
||||
Image and processes.
|
||||
|
||||
---
|
||||
|
||||
## Commits and release build
|
||||
|
||||
Commit on every meaningful step (a working sub-feature, a green build, end of a
|
||||
phase) — not every file save, and not only at phase end. Minimum: one commit per
|
||||
closed phase, plus intermediate commits for coherent sub-steps. Branch `main`
|
||||
unless a separate branch is requested. Push / PR only on explicit request.
|
||||
|
||||
Commit messages end with
|
||||
`Co-Authored-By: Claude <model> <noreply@anthropic.com>` for the model that did
|
||||
the step (e.g. `Claude Sonnet 4.6`).
|
||||
|
||||
Every such step also updates [CHANGELOG.md](../CHANGELOG.md) under
|
||||
`[Unreleased]` (Keep a Changelog). On an explicit version cut, rename
|
||||
`[Unreleased]` to `[X.Y.Z] - date` and open a fresh empty `[Unreleased]`. Image
|
||||
tag / push only on explicit request (see `release.yml`).
|
||||
|
||||
### Release image
|
||||
|
||||
The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
||||
`main`). The tag is the single source of version: it drives the image tag and
|
||||
`-ldflags` in the binaries so they cannot drift apart.
|
||||
|
||||
**Steps (on explicit request):**
|
||||
|
||||
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
|
||||
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||
local-trial image references) in the **same** release commit.
|
||||
2. Create and push git tag `vX.Y.Z` on that commit.
|
||||
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||
|
||||
Ordinary commits **do not** publish an image. The compose pin and the git tag
|
||||
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
|
||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development history before that cut.
|
||||
|
||||
---
|
||||
|
||||
## Phase closure
|
||||
|
||||
Before `/clear` at the end of a finished step:
|
||||
|
||||
1. Update [roadmap.md](roadmap.md) (and the active plan checklist, if any): what
|
||||
changed, what is next.
|
||||
2. Check the applicable «Done when…» criteria.
|
||||
3. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]`.
|
||||
4. Make the final commit for the step (when the user asks for a commit).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Static analysis and unit tests
|
||||
|
||||
Main module (`go test ./...`); e2e is a separate module — see below.
|
||||
|
||||
```sh
|
||||
make vet # go vet ./...
|
||||
make test # go test ./...
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```sh
|
||||
gofmt -l . # in CI — fails on drift
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Env documentation regression
|
||||
|
||||
`go test ./cmd/panel -run 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
|
||||
— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md)
|
||||
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
|
||||
|
||||
### End-to-end (container suite)
|
||||
|
||||
Separate Go module `test/e2e/`; **not** included in the main module's
|
||||
`go test ./...`.
|
||||
|
||||
```sh
|
||||
make e2e
|
||||
# same as: cd test/e2e && go test -v -timeout 20m ./...
|
||||
```
|
||||
|
||||
**Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) +
|
||||
[test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same
|
||||
`cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports
|
||||
(`20465`/`20587`/`20080`), test hostname, self-signed TLS,
|
||||
`PANEL_COOKIE_SECURE=false`, isolated compose project. Mail is hermetic:
|
||||
CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from
|
||||
the panel and published into the zone — the test verifies the records an
|
||||
operator would actually use.
|
||||
|
||||
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
|
||||
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
|
||||
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
|
||||
`docker restart`). Polling with timeouts only — no fixed `sleep`.
|
||||
|
||||
Requires **Docker + Compose v2** on the machine running the suite.
|
||||
|
||||
---
|
||||
|
||||
## CI
|
||||
|
||||
Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
||||
[§ Testing](#testing) above.
|
||||
|
||||
### `test.yml` — every push and PR to `main`
|
||||
|
||||
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
|
||||
|
||||
### `release.yml` — push of tag `vX.Y.Z` or `workflow_dispatch`
|
||||
|
||||
```
|
||||
prepare (version from tag)
|
||||
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||||
→ docker build --load (VERSION from tag)
|
||||
→ e2e (test/e2e)
|
||||
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||||
→ merge
|
||||
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||||
```
|
||||
|
||||
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
|
||||
emulation for e2e is impractical. E2e first, then push — the registry receives
|
||||
the bytes that passed the gate.
|
||||
|
||||
A failed e2e **blocks** image publication.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
History of deleted plans lives in git and [CHANGELOG.md](../CHANGELOG.md).
|
||||
There is no `docs/archive/` directory.
|
||||
|
||||
### Documentation map
|
||||
|
||||
| Home | File |
|
||||
|---|---|
|
||||
| Operator install / quick start | [README.md](../README.md) |
|
||||
| Operator guide | [guide.md](guide.md) |
|
||||
| Product boundaries | [product.md](product.md) |
|
||||
| As-built design | [architecture.md](architecture.md) |
|
||||
| Development process (this file) | [development.md](development.md) |
|
||||
| Security requirements and accepted risks | [security.md](security.md) |
|
||||
| Internal roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||
| Active design plans | [plans/](plans/) |
|
||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||
|
||||
### User-facing deliverables
|
||||
|
||||
| Artefact | Role |
|
||||
|---|---|
|
||||
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||||
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||||
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||
|
||||
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
||||
(candidates in [roadmap.md](roadmap.md)).
|
||||
|
||||
### Maintaining documentation
|
||||
|
||||
1. **Step rule:** a new or renamed env key, panel route, or observable mail-path
|
||||
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
|
||||
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
|
||||
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
|
||||
fails on an undocumented `loadConfig` or build-script key.
|
||||
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||
silent drive-by edits.
|
||||
|
||||
### Verifying docs against code
|
||||
|
||||
Every claim in the docs has a **source of truth in the tree**; verify from code
|
||||
to prose.
|
||||
|
||||
| Claim class | Source of truth |
|
||||
|---|---|
|
||||
| Env keys and defaults | `loadConfig` — [cmd/panel/main.go](../cmd/panel/main.go); `${VAR:-…}` in [build/](../build/) |
|
||||
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||||
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
|
||||
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||||
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||||
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||
| Product / out of scope | [product.md](product.md) |
|
||||
| As-built | [architecture.md](architecture.md) |
|
||||
| Mandatory security | [security.md](security.md) |
|
||||
|
||||
Order: list what the code actually does → find it in [guide.md](guide.md) /
|
||||
`architecture.md`. Before every tag, a short pass over this table — not a full
|
||||
prose rewrite.
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
# SelfPost operator guide
|
||||
|
||||
Detailed install, configuration, and day-to-day operations. For a short
|
||||
overview and quick start, see [README.md](../README.md).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
||||
- [Local trial](#local-trial)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [DNS setup](#dns-setup)
|
||||
- [IP warmup](#ip-warmup)
|
||||
- [Operations](#operations)
|
||||
- [Rate limiting](#rate-limiting)
|
||||
- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain)
|
||||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||
- [Published ports](#published-ports)
|
||||
- [Fixed image tag](#fixed-image-tag)
|
||||
|
||||
## Reverse proxy (mandatory)
|
||||
|
||||
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
|
||||
proxy in front of it is not optional. The proxy is also the project's only
|
||||
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
|
||||
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
|
||||
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
|
||||
service share one hostname — the common case — it's genuinely one certificate
|
||||
serving both.
|
||||
|
||||
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
||||
|
||||
| Proxy | Where certs live | Fragment |
|
||||
|---|---|---|
|
||||
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||||
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
||||
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
||||
|
||||
Apache is the recommended default because the certbot Apache plugin already
|
||||
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
||||
extra moving parts between "certificate issued" and "Postfix can read it."
|
||||
|
||||
**The proxy needs no security configuration of its own.** The panel emits its
|
||||
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
||||
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
|
||||
that's easy to get wrong lives in the service rather than in a config file
|
||||
somebody edits under pressure. There is exactly one thing the proxy must do:
|
||||
**pass the original `Host` header through**. All four fragments above already
|
||||
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
||||
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
||||
reject every form submission as cross-origin — the log says so explicitly,
|
||||
printing the `Origin` and `Host` it compared.
|
||||
|
||||
## Local trial
|
||||
|
||||
The [README quick start](../README.md#quick-start) runs a single container
|
||||
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
||||
reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status
|
||||
page will report missing TLS material until you mount PEM files at
|
||||
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
|
||||
|
||||
The one-time setup link is always printed as
|
||||
`https://<SELFPOST_HOSTNAME>/setup/<token>` (and written the same way to
|
||||
`/data/setup-token`). For a local trial that means rewriting the host and
|
||||
scheme to `http://127.0.0.1:8080/setup/<token>` — the path token is what
|
||||
matters; the hostname in the printed URL is not reachable as written.
|
||||
|
||||
**What works:** the full panel — setup, domains, applications, deliveries view,
|
||||
mail queue, system log. **What does not:** reliable outbound delivery to the
|
||||
public internet (no PTR, no real DNS for your domains, port 25 may be blocked
|
||||
on your network, HELO does not match anything receivers trust).
|
||||
|
||||
To exercise SMTP locally as well, generate a throwaway self-signed certificate
|
||||
and mount it before `docker run`:
|
||||
|
||||
```sh
|
||||
mkdir -p /tmp/selfpost-certs
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout /tmp/selfpost-certs/privkey.pem \
|
||||
-out /tmp/selfpost-certs/fullchain.pem \
|
||||
-days 1 -nodes -subj '/CN=mail.local.test'
|
||||
```
|
||||
|
||||
Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
|
||||
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
|
||||
Clients must skip TLS verification — the cert is not from a public CA.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
|
||||
`docker-compose.yml`. The table below lists every variable an operator is
|
||||
expected to set; defaults match the code exactly.
|
||||
|
||||
| Variable | Purpose | Default | Set in |
|
||||
|---|---|---|---|
|
||||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting](#rate-limiting). | `100` | `.env` |
|
||||
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||||
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
|
||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
||||
|
||||
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||
bind mount — configure the mount, not these variables.
|
||||
|
||||
**Internal variables (not part of the operator interface).** The following are
|
||||
read by the panel or startup scripts but are not meant to be changed in a
|
||||
normal deployment; documenting them here avoids treating accidental overrides as
|
||||
supported configuration:
|
||||
|
||||
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||
here has to be matched in `build/postfix-config.sh`),
|
||||
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
|
||||
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
||||
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
|
||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
||||
desyncs the map Postfix reads from the file the panel maintains).
|
||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
||||
`postfix reload` to pick up renewed certificates),
|
||||
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
||||
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
||||
rotation triggers `postfix reload`).
|
||||
|
||||
## DNS setup
|
||||
|
||||
Two different scopes — don't confuse them:
|
||||
|
||||
**Server level (once, for the machine itself):**
|
||||
- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most
|
||||
receiving mail servers weigh this heavily; get it from whoever assigns the IP
|
||||
(hosting provider's panel/support), not from your own DNS zone.
|
||||
|
||||
**Domain level (for *every* sending domain you add in the panel):**
|
||||
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
||||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||
- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts
|
||||
building reporting/reputation history).
|
||||
|
||||
Skipping any of the three per-domain records is the single most common reason
|
||||
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||
panel, add its DNS records at the same time**, not later.
|
||||
|
||||
The panel checks both scopes for you and tells you what is actually published:
|
||||
the *Status* page verifies the server's hostname and its reverse record
|
||||
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
||||
card comparing the published DKIM record against the key this server signs with,
|
||||
plus the domain's SPF and DMARC records. Results are cached for a few minutes;
|
||||
use *Re-check* right after publishing a record. The SPF check is deliberately
|
||||
shallow — it looks for a mechanism that literally covers this server's address
|
||||
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
||||
server through an include is reported as "cannot tell" rather than as a failure.
|
||||
|
||||
## IP warmup
|
||||
|
||||
A brand-new IP has no sending history, so receiving servers are cautious with
|
||||
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
|
||||
a domain, increase gradually over days/weeks rather than sending everything on
|
||||
day one, and check the IP against major blocklists (Spamhaus and similar)
|
||||
before and during warmup. This is inherent to how mail reputation works on the
|
||||
public internet, not something SelfPost's configuration can shortcut.
|
||||
|
||||
## Operations
|
||||
|
||||
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||
service healthy and will mail be accepted?"
|
||||
|
||||
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
|
||||
TLS certificate validity and expiry, milter socket presence, and a short
|
||||
Postfix queue summary. The **Machine** card adds the resource usage of the
|
||||
host underneath — processor (with the load average), memory and swap, and
|
||||
per-interface network throughput and totals — read from the kernel's
|
||||
counters; CPU and throughput are measured between refreshes, so they appear
|
||||
one refresh after the page opens. A fully busy processor or a machine out of
|
||||
memory is a warning here, because both delay or kill the mail path;
|
||||
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
||||
against the PTR record the internet publishes for this server's IP
|
||||
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The
|
||||
**Reload configuration** button re-applies OpenDKIM tables and the Postfix
|
||||
sender map from the database — use it if daemons drifted from what the panel
|
||||
shows after manual edits under `/data`.
|
||||
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
||||
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
|
||||
(level 2) are configured here. *Export domain* writes a single-domain archive;
|
||||
*Import a domain* on the Backup page reads one back in.
|
||||
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
||||
by domain and application. A row identifies its message and nothing more —
|
||||
time, sender, recipient, subject and status `queued` (accepted, not yet
|
||||
delivered), `sent` (handed off successfully), `deferred` (Postfix is retrying),
|
||||
`bounced` (final failure), or `rejected` (refused — for example by a level-2
|
||||
rate limit); *Details* opens that row's own page
|
||||
(`/deliveries/{id}`). That page carries the sending domain, the application it
|
||||
was submitted under, the Postfix queue id and the journal id, beside the
|
||||
message's history — when it was accepted and what Postfix later reported for
|
||||
the recipient — and, under both, the `mail.log` lines for its queue id: the
|
||||
connection to the receiving server, the server's reply, and the status that
|
||||
reply was filed as. Rows outlive `mail.log`, so an older message's lines may
|
||||
have rotated away; the page says so. Retention is controlled by
|
||||
`SEND_LOG_RETENTION_DAYS`.
|
||||
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||
trying to deliver or deferring.
|
||||
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
||||
related daemon lines). The log rotates daily (14 files kept) with a
|
||||
`postfix reload` after each rotation; a background loop checks every six
|
||||
hours. It lives in the data volume, so it survives a container recreate along
|
||||
with the rest of the state — `./data/log/` on the host — but it is *not*
|
||||
included in backups: it is diagnostics, not state.
|
||||
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
||||
the domain-import form (`POST /domains/import`). See
|
||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
||||
- **Settings** (`/account`) — change the administrator username and/or password.
|
||||
Application SASL logins are separate and are not changed here.
|
||||
|
||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
||||
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
|
||||
seven days) with no absolute lifetime cap — an admin who keeps using the panel
|
||||
stays signed in indefinitely. HTMX polling on the monitoring screens
|
||||
(Deliveries, Mail queue, System log, and the Status health fragment) does
|
||||
**not** count as activity, so an auto-refreshing tab left open will not keep a
|
||||
session alive forever. Changing the password signs out every other session but
|
||||
leaves the current browser signed in.
|
||||
|
||||
**Upgrading.** Bump the pinned image tag in `docker-compose.yml` to the target
|
||||
release, then `docker compose up -d`. The backup version check requires the
|
||||
running image to match the version that created a full backup — see [Fixed image
|
||||
tag](#fixed-image-tag).
|
||||
|
||||
**Container health.** The image declares a Docker `HEALTHCHECK` that probes
|
||||
`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when
|
||||
OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord;
|
||||
otherwise `503 unhealthy`. This catches a dead mail path that would still leave
|
||||
the HTTP server up, but it does **not** verify TLS certificates, DNS records,
|
||||
or end-to-end delivery — use the authenticated **Status** page for that. External
|
||||
monitoring can use the same endpoint through the reverse proxy if you expose it,
|
||||
or poll `docker inspect` health state on the host.
|
||||
|
||||
**First-time setup link.** On first start the one-time setup URL is printed in
|
||||
the container log (`docker compose logs -f`) and written to `/data/setup-token`
|
||||
inside the container — `./data/setup-token` on the host, mode `0600` — then
|
||||
deleted when setup completes. The link is
|
||||
`https://<SELFPOST_HOSTNAME>/setup/<token>` (path token, not a query string),
|
||||
valid for ten minutes. If this host ships container logs to a central
|
||||
aggregator, prefer reading the file:
|
||||
|
||||
```sh
|
||||
docker compose exec selfpost cat /data/setup-token
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
|
||||
SelfPost applies two independent limits; both can refuse a submission, but only
|
||||
level 2 writes a `rejected` row in the send log.
|
||||
|
||||
**Level 1 (IP backstop)** — always on, configured via `.env`:
|
||||
|
||||
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
||||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||
|
||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||
journal-milter (level 2) is down.
|
||||
|
||||
**Level 2 (per domain / per application)** — optional, configured in the panel
|
||||
on each domain's page or on an individual application. You set a message
|
||||
ceiling, a time window, and optionally restrict the limit to specific client
|
||||
IPs; an empty IP list means the differentiated limit does not apply. When
|
||||
exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as
|
||||
`rejected`.
|
||||
|
||||
## Backup, restore, and moving a single domain
|
||||
|
||||
Two related but distinct operations
|
||||
([architecture.md](architecture.md) § Persistence):
|
||||
|
||||
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
|
||||
all applications' SASL credentials, `manifest.json` with the version that
|
||||
created it): panel button (*Backup* → *Full backup*), or from the
|
||||
host:
|
||||
```sh
|
||||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
```
|
||||
**Restore** means unpacking that archive into a fresh `/data` bind mount and
|
||||
starting a container of the **exact same image version** that created it —
|
||||
SelfPost refuses to start otherwise and tells you which tag to use. On the
|
||||
first successful start after restore, `manifest.json` from the archive is
|
||||
**deleted** — it guards only that one boot, so a later in-place upgrade is
|
||||
not blocked. This is why the compose file pins a fixed tag rather than
|
||||
`:latest`: without a known version, there'd be no way to tell which image
|
||||
restoring a given backup actually requires.
|
||||
|
||||
**Alternative: archive `./data` while stopped.** If the service can be taken
|
||||
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
|
||||
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
|
||||
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
|
||||
bulk of the archive; add `--exclude=./data/log` if you only want the state.
|
||||
Do **not** tar `./data` while
|
||||
the container is running: the database uses WAL mode and a naive copy can
|
||||
capture an inconsistent snapshot. The panel/CLI backup remains preferable when
|
||||
you cannot afford downtime because it takes a consistent SQLite snapshot via
|
||||
the Backup API on a live container.
|
||||
|
||||
- **Export/import a single domain** (domain page → *Export domain* to write the
|
||||
file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working**
|
||||
SASL passwords — to a different SelfPost instance without regenerating
|
||||
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full
|
||||
restore, this works across different hostnames/instances.
|
||||
|
||||
Both files are **secrets** — they contain the admin password hash (full
|
||||
backup) or working application credentials (domain export) in the clear or in
|
||||
directly reversible form. Treat them like any other credential material:
|
||||
restrict who can read them, don't email them around — and encrypt them, which
|
||||
SelfPost can do for you.
|
||||
|
||||
### Encrypting a backup or export
|
||||
|
||||
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
|
||||
file that comes down is an encrypted envelope instead of the plain archive:
|
||||
|
||||
| Artefact | Plain | Encrypted |
|
||||
|----------|-------|-----------|
|
||||
| Full backup | `.tar.gz` | `.spbk` (**S**elf**P**ost **b**ac**k**up) |
|
||||
| Domain export | `.json` | `.spde` (**S**elf**P**ost **d**omain **e**xport) |
|
||||
|
||||
The suffixes are for the operator only — the server detects an encrypted file
|
||||
by its magic bytes (`SELFPOST1`), not by the extension.
|
||||
|
||||
The key is derived from the password with scrypt and the contents are sealed
|
||||
with AES-256-GCM, in chunks, so a truncated or altered file fails to open rather
|
||||
than restoring quietly. **SelfPost does not store the password** — lose it and
|
||||
the file is unrecoverable, which is the entire point.
|
||||
|
||||
*Import a domain* takes an encrypted export directly: choose a `.spde` file and
|
||||
the password field appears (driven by the file extension in the browser; the
|
||||
server also detects the envelope by its magic bytes). A plain `.json` export
|
||||
needs no password.
|
||||
|
||||
A full backup has to be turned back into a plain archive before it can be
|
||||
unpacked into `/data`, which the CLI does with the same password:
|
||||
|
||||
```sh
|
||||
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
```
|
||||
|
||||
The CLI also *writes* encrypted backups for scripted/cron use. The password
|
||||
comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file <path>` (first line),
|
||||
never from a command-line argument, which would be visible in the process list:
|
||||
|
||||
```sh
|
||||
docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > backup.spbk
|
||||
```
|
||||
|
||||
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||
|
||||
## Published ports
|
||||
|
||||
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
|
||||
(smtps) is always active. Port **587** is published even when
|
||||
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
|
||||
but it can look like an open port in external scans.
|
||||
|
||||
## Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `1.0.0`. Intermediate
|
||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||
image was published. Pinning matters because of the backup version check above:
|
||||
the panel binary's embedded version and the image tag that produced it are the
|
||||
same value by construction (the release CI stamps both from one git tag — see
|
||||
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
||||
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
||||
deliberately, not by riding a moving target.
|
||||
@@ -0,0 +1,70 @@
|
||||
# План: domain-admin (роль администратора домена)
|
||||
|
||||
**Статус:** согласовано
|
||||
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
|
||||
в глобального.
|
||||
**Порядок:** рекомендуется после [web-split](web-split.md), до
|
||||
[inbound-relay](inbound-relay.md).
|
||||
|
||||
---
|
||||
|
||||
## Что это
|
||||
|
||||
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль
|
||||
([web.go](../../internal/web/web.go) — обёртка
|
||||
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта
|
||||
входа.
|
||||
|
||||
Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким);
|
||||
перечень доменов определяет **глобальный администратор**. Для каждого домена из
|
||||
списка:
|
||||
|
||||
- приложения этого домена (создание, режим отправителя, перегенерация пароля,
|
||||
удаление, свой L2-лимит);
|
||||
- DKIM/DNS-статус домена;
|
||||
- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть
|
||||
([sendLogData](../../internal/web/handlers_monitor.go)).
|
||||
|
||||
Вне роли остаётся то, что глобально по своей природе:
|
||||
|
||||
- добавление и удаление доменов;
|
||||
- создание domain-admin пользователей и назначение им доменов;
|
||||
- `/reload`;
|
||||
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены
|
||||
сразу);
|
||||
- очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
||||
|
||||
## Почему расширение v1.0
|
||||
|
||||
[product.md](../product.md) относит «несколько пользователей панели, роли» к
|
||||
out of scope (один администратор). Появление второго субъекта — сознательное
|
||||
расширение границ проекта, как и inbound-relay.
|
||||
|
||||
Цена — уровня фазы, а не патча:
|
||||
|
||||
- таблица пользователей и их привязка к доменам;
|
||||
- роль в сессии;
|
||||
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
|
||||
не сверяются ни с чем, кроме существования);
|
||||
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
|
||||
- учёт нового субъекта в бэкапе и экспорте домена.
|
||||
|
||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» —
|
||||
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
|
||||
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
|
||||
ограниченный доступ владельца одного или нескольких доменов — перечень задаёт
|
||||
глобальный администратор.)*
|
||||
|
||||
## Готово, когда
|
||||
|
||||
- Глобальный администратор и domain-admin с разными правами работают через
|
||||
панель; domain-admin не может выйти за пределы **назначенных** доменов;
|
||||
- текущий единственный админ мигрирует в глобального без потери доступа;
|
||||
- бэкап/восстановление учитывает пользователей и привязки;
|
||||
- `build`/`vet`/`test`/образ зелёные.
|
||||
|
||||
## Риски
|
||||
|
||||
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
|
||||
домену;
|
||||
- breaking setup/бэкап — тогда semver major, не 1.x.
|
||||
@@ -0,0 +1,159 @@
|
||||
# План: inbound-relay (входящий релей)
|
||||
|
||||
**Статус:** согласовано
|
||||
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по
|
||||
итогам реализации (не фиксировать major заранее).
|
||||
**Модель:** Opus (инфра/безопасность, риск open relay).
|
||||
**Порядок:** рекомендуется после [web-split](web-split.md) и
|
||||
[domain-admin](domain-admin.md).
|
||||
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать
|
||||
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как
|
||||
выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность
|
||||
атаки базового исходящего релея.
|
||||
|
||||
## Зачем это нужно (сценарии)
|
||||
|
||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно
|
||||
недоступен, и передать её, когда он вернётся.
|
||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер,
|
||||
который по каким-то причинам **сам не может принимать почту из интернета**
|
||||
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на
|
||||
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает
|
||||
публичным входным узлом для домена (MX указывает на него) и пересылает почту
|
||||
на этот внутренний/недоступный извне сервер.
|
||||
|
||||
## Граница объёма (критично — что это НЕ)
|
||||
|
||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward)
|
||||
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`).
|
||||
Postfix здесь — чистый пересыльщик, без локальной доставки.
|
||||
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в
|
||||
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost
|
||||
также **не реализует и не тянет в свой образ** движок антиспама/антивируса
|
||||
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не**
|
||||
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет
|
||||
точку подключения внешнего фильтра.
|
||||
|
||||
## Почему как опция/плагин
|
||||
|
||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter,
|
||||
spam-ingress). Поэтому по умолчанию **выключено** флагом env
|
||||
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели,
|
||||
отдельная ветка генерации конфига. При выключенном флаге входной listener,
|
||||
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
||||
|
||||
## Что делать
|
||||
|
||||
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать
|
||||
входной сервис и его конфиг из состояния панели тем же путём, что остальной
|
||||
конфиг (`postfix-config.sh`).
|
||||
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25
|
||||
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не**
|
||||
предлагается SASL и **не** разрешается отправка наружу — только приём для
|
||||
`relay_domains`.
|
||||
- **Анти-open-relay для входящей (обязательно):**
|
||||
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd
|
||||
принимают почту **только** для доменов из `relay_domains` и **только** для
|
||||
известных получателей (`relay_recipient_maps`); всё прочее —
|
||||
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём
|
||||
«для кого угодно» невозможны.
|
||||
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown
|
||||
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
||||
- **Панель управляет:** список входящих доменов; для каждого — upstream
|
||||
destination (`host:port`, транспорт), опциональный список валидных
|
||||
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта
|
||||
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе
|
||||
4), `os/exec` без shell ([security.md](../security.md)).
|
||||
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не
|
||||
подписываем). journal-milter опционально переиспользовать для журнала входящих
|
||||
(доп. работа) либо на первом этапе оставить входящий без него; поведение
|
||||
fail-open сохраняется.
|
||||
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и
|
||||
`message_size_limit` на входном smtpd.
|
||||
|
||||
## Антиспам (важная, но опциональная возможность)
|
||||
|
||||
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит
|
||||
**слепая пересылка без фильтрации** — например, когда backend сам умеет
|
||||
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик.
|
||||
Поэтому антиспам-хук по умолчанию **выключен** (пустой
|
||||
`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него.
|
||||
|
||||
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой
|
||||
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя,
|
||||
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация
|
||||
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF
|
||||
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP —
|
||||
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть
|
||||
*подключаема именно здесь*, а не переложена на backend, который эту информацию
|
||||
уже потерял.
|
||||
|
||||
Дизайн подключения:
|
||||
|
||||
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.),
|
||||
который оператор запускает **только если нужна эта опция** (тот же принцип,
|
||||
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его
|
||||
**не содержит и не запускает** — образ и принцип «один контейнер, три
|
||||
процесса» неизменны, [product.md](../product.md) out of scope не нарушается
|
||||
(SelfPost не реализует антиспам).
|
||||
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd.
|
||||
Адрес движка задаётся env (например,
|
||||
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и
|
||||
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587).
|
||||
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит
|
||||
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый
|
||||
(fail-open vs tempfail); дефолт определить при реализации.
|
||||
- **Нативный backstop без зависимостей:** на том же входном хопе доступны
|
||||
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки
|
||||
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение
|
||||
аутентификации для downstream через ARC/`Received` там, где часть фильтрации
|
||||
всё же остаётся на backend.
|
||||
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара
|
||||
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со
|
||||
стеком только при включённой опции.
|
||||
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный
|
||||
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей
|
||||
конфигурацией — опционально, пометить.
|
||||
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на
|
||||
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS
|
||||
README.
|
||||
|
||||
## Безопасность
|
||||
|
||||
[security.md](../security.md): валидация ввода на сервере, экранирование записи
|
||||
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
||||
|
||||
## Готово, когда
|
||||
|
||||
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого
|
||||
домена пересылается на заданный upstream; почта для ненастроенных
|
||||
доменов/получателей отклоняется (не open relay, не backscatter); при заданном
|
||||
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим
|
||||
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при
|
||||
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый
|
||||
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
||||
|
||||
## Риски
|
||||
|
||||
- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` +
|
||||
`reject_unauth_destination`;
|
||||
- потеря origin IP для фильтрации на backend'е при пересылке — снимается
|
||||
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё
|
||||
виден;
|
||||
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено);
|
||||
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
|
||||
возможен major `2.x`; решение после реализации.
|
||||
|
||||
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
|
||||
SelfPost, поднимается оператором при включении опции.
|
||||
|
||||
## Зависимости
|
||||
|
||||
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
|
||||
выше.
|
||||
@@ -0,0 +1,52 @@
|
||||
# План: web-split (разбиение `internal/web`)
|
||||
|
||||
**Статус:** согласовано
|
||||
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
|
||||
|
||||
---
|
||||
|
||||
## Что это
|
||||
|
||||
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
|
||||
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
|
||||
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
|
||||
рендер шаблонов.
|
||||
|
||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
||||
панели.
|
||||
|
||||
## Почему сейчас
|
||||
|
||||
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
|
||||
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
|
||||
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
|
||||
есть расширение внутреннего API ради косметики.
|
||||
|
||||
Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay**
|
||||
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
|
||||
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
|
||||
делать перед этим ростом, чем после.
|
||||
|
||||
## Рекомендуемый порядок
|
||||
|
||||
**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)).
|
||||
|
||||
1. **web-split** — заложить структуру пакета (в т.ч. место под `web/auth`), пока
|
||||
нет сквозных правок от роли и новых inbound-хендлеров.
|
||||
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную
|
||||
схему пакета.
|
||||
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный
|
||||
пакет, чем рефакторить вместе с двумя предыдущими фичами.
|
||||
|
||||
Порядок рекомендация, не блокер.
|
||||
|
||||
## Готово, когда
|
||||
|
||||
Решение принято осознанно в момент старта работ — либо пакет разрезан по
|
||||
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
|
||||
`build`/`vet`/`test` зелёные, поведение панели неизменно.
|
||||
|
||||
## Риски
|
||||
|
||||
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
|
||||
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# SelfPost — product boundaries
|
||||
|
||||
**What this file is.** Stable product definition for SelfPost v1.0: purpose,
|
||||
deployment assumptions, explicit out-of-scope items, and the multi-domain
|
||||
model. User-facing overview in [README.md](../README.md); install and operations
|
||||
in [guide.md](guide.md); as-built technical detail in
|
||||
[architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
SelfPost is a self-hosted **outbound SMTP relay** with a web control panel,
|
||||
shipped as a single Docker image. It sends mail directly to the internet from
|
||||
your own IP with per-domain DKIM signing.
|
||||
|
||||
**Primary workflow:** configure the relay once (domains, DKIM, SASL
|
||||
applications), then point scripts and apps at the SMTP endpoint. SelfPost
|
||||
delivers as the configured sending domain.
|
||||
|
||||
---
|
||||
|
||||
## Deployment context (fixed assumptions)
|
||||
|
||||
These constraints are intentional; changing them requires an explicit product
|
||||
decision:
|
||||
|
||||
1. **VPS or home server** — one image for both. Raspberry Pi and similar boards
|
||||
are not a current target but not ruled out forever.
|
||||
2. **Send from your own IP (DIY)** — no intermediate relay provider. Requires
|
||||
outbound port 25, static IP, and configurable PTR/rDNS from the operator.
|
||||
3. **Single container** — Postfix, OpenDKIM, and the panel run under one
|
||||
`supervisord` inside one image.
|
||||
4. **Panel is internet-facing** — mandatory security requirements in
|
||||
[security.md](security.md).
|
||||
|
||||
**Infrastructure the operator provides (not SelfPost features):** unblocked
|
||||
outbound TCP 25, static IP, PTR/rDNS, acceptable IP reputation. SelfPost does
|
||||
not detect, bypass, or compensate for missing prerequisites; mail simply fails
|
||||
to deliver when they are absent.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Explicitly excluded to prevent scope creep:
|
||||
|
||||
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
||||
- Webmail
|
||||
- Multi-user panel / organisations / roles — one administrator; managing
|
||||
**multiple sending domains** is in scope (see below)
|
||||
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
||||
- A custom MTA — Postfix is used as-is
|
||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||
|
||||
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
||||
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
||||
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
||||
implementation. Items still marked *candidate* in the roadmap require explicit
|
||||
approval before coding.
|
||||
|
||||
---
|
||||
|
||||
## Multi-domain model
|
||||
|
||||
SelfPost is a **multi-domain outbound relay**. Two linked entities:
|
||||
|
||||
### Sending domain
|
||||
|
||||
Example: `example.com`. Has its own DKIM key and selector.
|
||||
|
||||
### Application (account)
|
||||
|
||||
A SASL login/password bound to **one** domain. A domain may have several
|
||||
applications (e.g. newsletter vs alerts), each with its own credentials, but
|
||||
each may send **only from its own domain**, never from another.
|
||||
|
||||
### From-address mode (per application)
|
||||
|
||||
Set when creating or editing an application:
|
||||
|
||||
1. **Any address in the domain** — `*@example.com` (wildcard in
|
||||
`smtpd_sender_login_maps`).
|
||||
2. **Explicit address list** — only listed From addresses within the domain;
|
||||
anything else is rejected even if it belongs to the same domain.
|
||||
|
||||
In both modes the From address must belong to the application's domain.
|
||||
|
||||
### What the panel manages per domain
|
||||
|
||||
- DKIM key + selector (shared by all applications on that domain)
|
||||
- One or more applications (SASL credentials + From mode + optional rate limits)
|
||||
- DNS guidance (DKIM TXT; reminders for SPF/DMARC)
|
||||
|
||||
Adding a domain does **not** create an application automatically.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- **Add domain** — record, DKIM key, DNS instructions; no application yet.
|
||||
- **Add application** — SASL pair (password shown once), From mode, map entries,
|
||||
`postfix reload`.
|
||||
- **Delete domain** — removes DKIM key and **all** its applications.
|
||||
- **Delete application** — removes only that app's SASL and map entries.
|
||||
|
||||
This is not multi-tenancy (one admin); it is one owner operating several
|
||||
sending domains with independent application credentials.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Дорожная карта: открытая работа (1.x+)
|
||||
|
||||
**Статус:** внутренний трекер расширений границ v1.0 после явного согласования
|
||||
([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||
Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK
|
||||
до кодирования.
|
||||
|
||||
**Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если
|
||||
дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking.
|
||||
|
||||
**Процесс:** [development.md](development.md). История закрытых фаз — в `git log`
|
||||
и [CHANGELOG.md](../CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Индекс
|
||||
|
||||
| ID | Тема | Статус | План |
|
||||
|---|---|---|---|
|
||||
| web-split | Разбиение `internal/web` | **согласовано** | [plans/web-split.md](plans/web-split.md) |
|
||||
| domain-admin | Роль администратора домена | **согласовано** | [plans/domain-admin.md](plans/domain-admin.md) |
|
||||
| inbound-relay | Входящий релей (backup-MX / пересылка) | **согласовано** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||
| contributing | `CONTRIBUTING.md` | кандидат | — |
|
||||
|
||||
**Рекомендуемый порядок** (не обязателен): **web-split → domain-admin →
|
||||
inbound-relay** — сначала разрез пакета, затем сквозная авторизация роли, затем
|
||||
новый вертикальный срез входящего релея. Отклонение допустимо; жёстких фаз нет.
|
||||
|
||||
После `/clear` — пункт со статусом `согласовано` или `в работе`, затем чеклист в
|
||||
linked plan.
|
||||
|
||||
---
|
||||
|
||||
## inbound-relay
|
||||
|
||||
**Цель:** опциональный приём почты на порт 25 для явно настроенных доменов и
|
||||
пересылка на upstream (backup-MX / relay-forwarder). По умолчанию выключено
|
||||
(`INBOUND_RELAY_ENABLE=false`); исходящий тракт без флага не меняется.
|
||||
|
||||
**Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей
|
||||
почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне
|
||||
образа, только точка подключения.
|
||||
|
||||
**Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md).
|
||||
|
||||
**Зависимости / риски:** готовый исходящий тракт; open relay/backscatter;
|
||||
расширение поверхности атаки (порт 25 на приём). Модель: Opus.
|
||||
**Порядок:** рекомендуется после [web-split](plans/web-split.md) и
|
||||
[domain-admin](plans/domain-admin.md).
|
||||
**Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации.
|
||||
|
||||
---
|
||||
|
||||
## domain-admin
|
||||
|
||||
**Цель:** роль с доступом к одному или нескольким назначенным доменам
|
||||
(перечень задаёт глобальный администратор) — приложения, DKIM/DNS, журнал
|
||||
отправки по каждому из них; без глобальных операций (добавление доменов, полный
|
||||
бэкап, очередь, `mail.log`).
|
||||
|
||||
**Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного
|
||||
администратора. Не вторая копия всевластного админа, а ограниченный доступ к
|
||||
назначенным доменам (одному или нескольким).
|
||||
|
||||
**Готово, когда:** см. [plans/domain-admin.md](plans/domain-admin.md).
|
||||
|
||||
**Зависимости / риски:** таблица пользователей, роль в сессии, авторизация в
|
||||
каждом хендлере, setup/бэкап. **Порядок:** рекомендуется после
|
||||
[web-split](plans/web-split.md), до [inbound-relay](plans/inbound-relay.md).
|
||||
**Версия:** `1.x` MINOR при совместимой миграции текущего админа в глобального.
|
||||
|
||||
---
|
||||
|
||||
## web-split
|
||||
|
||||
**Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет)
|
||||
перед ростом от inbound-relay и domain-admin.
|
||||
|
||||
**Граница:** внутренний рефакторинг; поведение панели для оператора не меняется.
|
||||
|
||||
**Готово, когда:** пакет разрезан по выбранной схеме или зафиксировано, что
|
||||
остаётся плоским — см. [plans/web-split.md](plans/web-split.md).
|
||||
|
||||
**Зависимости / риски:** экспорт пакетно-приватного API. **Порядок:** рекомендуется
|
||||
**первым** среди согласованных фич (до domain-admin и inbound-relay).
|
||||
**Версия:** `1.x`, сам по себе breaking не тянет.
|
||||
|
||||
---
|
||||
|
||||
## contributing
|
||||
|
||||
**Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол
|
||||
коммитов; [development.md](development.md) ссылается, не дублирует.
|
||||
|
||||
**Граница:** документация процесса; уместна при внешнем потоке PR.
|
||||
|
||||
**Готово, когда:** файл в корне; development.md не дублирует его.
|
||||
|
||||
**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет.
|
||||
**Версия:** без значения для semver.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Безопасность
|
||||
|
||||
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
|
||||
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
|
||||
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
|
||||
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
|
||||
defence-in-depth — `--` перед логином в argv `saslpasswd2`
|
||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** —
|
||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
||||
|
||||
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
|
||||
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
|
||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
|
||||
|
||||
Продуктовые границы: [product.md](product.md). Устройство as-built:
|
||||
[architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Обязательные требования
|
||||
|
||||
Панель публична из интернета — пункты ниже **не опциональны**.
|
||||
|
||||
### Первичная инициализация администратора
|
||||
|
||||
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
|
||||
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
|
||||
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
|
||||
настройки — перегенерация и новый вывод в лог.
|
||||
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
|
||||
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
|
||||
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
|
||||
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
|
||||
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
|
||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
|
||||
|
||||
### SASL-пароли приложений
|
||||
|
||||
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
|
||||
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
|
||||
перевыпуск.
|
||||
|
||||
### Ввод и конфигурация
|
||||
|
||||
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
|
||||
защитой.
|
||||
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
|
||||
- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского
|
||||
ввода; аргументы отдельными элементами.
|
||||
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
|
||||
|
||||
### Аутентификация и сессии
|
||||
|
||||
- Rate limiting на логин (по IP, с блокировкой/задержкой).
|
||||
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
|
||||
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
|
||||
(`PANEL_SESSION_IDLE_DAYS`).
|
||||
|
||||
### Вывод и процесс
|
||||
|
||||
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
|
||||
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
|
||||
группу `selfpost` и минимальные права.
|
||||
|
||||
### Почтовый тракт (связанное с безопасностью)
|
||||
|
||||
- **Не open relay** — только SASL; `reject_unauth_destination`;
|
||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
|
||||
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
|
||||
при rate-limit логина; пусто = XFF игнорируется.
|
||||
|
||||
### Резервная копия и экспорт домена
|
||||
|
||||
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
|
||||
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
|
||||
текстом (иначе перенос без пересоздания кредов невозможен).
|
||||
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
|
||||
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
|
||||
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
|
||||
подменённый файл не открывается вместо тихого восстановления «хвоста».
|
||||
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
|
||||
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
|
||||
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
|
||||
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
|
||||
шифрование по magic файла, а не по расширению.
|
||||
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
|
||||
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
|
||||
аргументом (список процессов читается любым процессом контейнера).
|
||||
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
|
||||
offline и подбирается без ограничений по времени.
|
||||
|
||||
---
|
||||
|
||||
## Принятые риски
|
||||
|
||||
Принятый риск — решение с условием возврата, а не отложенная задача из
|
||||
дорожной карты.
|
||||
|
||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
||||
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
|
||||
Принято сознательно: панель однопользовательская, админ выбирает браузер
|
||||
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|
||||
панель. Ужесточение — одна строка в `originAllowed`
|
||||
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
|
||||
вместо `true` в ветке «нет обоих заголовков».
|
||||
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
|
||||
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
|
||||
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
|
||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
||||
inline-скриптов и inline-стилей.
|
||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
||||
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
|
||||
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
|
||||
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
|
||||
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
|
||||
дочитывается. Остаток — строки, delivery-строки которых потеряны
|
||||
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
|
||||
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
|
||||
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
|
||||
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
|
||||
не может подтвердить, не должна показываться как подтверждённая, а вечный
|
||||
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
|
||||
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
|
||||
читается. См. [architecture.md](architecture.md) § Log tailer.
|
||||
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
|
||||
`/data/log` — `2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
|
||||
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
|
||||
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
|
||||
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
|
||||
оставалась состоянием, а не диагностикой.
|
||||
|
||||
## ADR: CSRF через проверку Origin, без токенов
|
||||
|
||||
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
|
||||
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
|
||||
инициированного сторонним сайтом в браузере залогиненного админа.
|
||||
|
||||
**Решение.** `originAllowed` в
|
||||
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
|
||||
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
|
||||
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
|
||||
сессии и встроенных в формы, нет.
|
||||
|
||||
**Почему не токены.** Панель однопользовательская (один администратор на
|
||||
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
|
||||
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
|
||||
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
|
||||
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
|
||||
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
|
||||
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
|
||||
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
|
||||
обороны, не CSRF-токен.
|
||||
|
||||
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
|
||||
(по-настоящему старый браузер или webview с замороженным движком), остаётся
|
||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
||||
|
||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
||||
браузера, или панель станет многопользовательской.
|
||||
|
||||
## Как этот список пополняется
|
||||
|
||||
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
||||
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
||||
Третьего варианта («посмотрели и ладно») нет.
|
||||
@@ -1,429 +0,0 @@
|
||||
# Техническое задание: SelfPost
|
||||
|
||||
**Версия:** 1.0
|
||||
**Тип проекта:** self-hosted SMTP relay с веб-панелью управления
|
||||
**Формат поставки:** один Docker-образ
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель и назначение
|
||||
|
||||
SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для **исходящей отправки** почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель.
|
||||
|
||||
**Ключевой сценарий использования:** пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена.
|
||||
|
||||
---
|
||||
|
||||
## 2. Контекст и ограничения
|
||||
|
||||
Эти ограничения — результат предварительного анализа и **не подлежат пересмотру** без явного согласования:
|
||||
|
||||
1. **Развёртывание — VPS или домашний сервер** (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже).
|
||||
2. **Отправка с собственного IP** (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS.
|
||||
3. **Один контейнер.** Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания.
|
||||
4. **Панель управления публично доступна** из интернета. Это накладывает обязательные требования к безопасности (раздел 7).
|
||||
|
||||
> **Предпосылки инфраструктуры (вне зоны ответственности проекта).** SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо.
|
||||
|
||||
---
|
||||
|
||||
## 3. Что НЕ входит в объём (Out of Scope)
|
||||
|
||||
Явно исключено, чтобы не было scope creep:
|
||||
|
||||
- ❌ Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики)
|
||||
- ❌ Веб-почта (webmail)
|
||||
- ❌ Мультитенантность (организации, несколько **пользователей панели**, роли) — панель рассчитана на **одного** администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1).
|
||||
- ❌ Антиспам/антивирус для входящей почты (rspamd, ClamAV)
|
||||
- ❌ Реализация собственного SMTP-сервера или MTA — используется готовый Postfix
|
||||
- ❌ Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (`sasldb2`), см. раздел 5.1
|
||||
|
||||
---
|
||||
|
||||
## 4. Архитектура
|
||||
|
||||
Единый Docker-образ на базе **Debian slim** (например, `debian:bookworm-slim`). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением `supervisord`:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Docker-контейнер SelfPost │
|
||||
│ │
|
||||
│ supervisord (PID 1) │
|
||||
│ ├── postfix (start-fg) │
|
||||
│ ├── opendkim (foreground) │
|
||||
│ └── panel (Go-бинарник, один процесс, │
|
||||
│ совмещает несколько ролей: │
|
||||
│ • HTTP-сервер панели :8080 │
|
||||
│ • journal-milter (приём :internal │
|
||||
│ From/To/Subject/SASL-user на этапе │
|
||||
│ DATA, см. 7.3) │
|
||||
│ • log-tailer — горутина, следит за │
|
||||
│ mail.log и обновляет статус доставки) │
|
||||
│ │
|
||||
│ Общая файловая система: │
|
||||
│ /etc/postfix/... ← панель пишет │
|
||||
│ /etc/opendkim/keys ← DKIM-ключи │
|
||||
│ /data/selfpost.db ← SQLite: домены, │
|
||||
│ приложения, администратор, журнал │
|
||||
│ отправки (см. 7.3, 9) │
|
||||
│ │
|
||||
│ Порты: │
|
||||
│ 465 (smtps, implicit TLS — основной, только │
|
||||
│ SASL-аутентификация) │
|
||||
│ 587 (submission, STARTTLS — опционально, │
|
||||
│ добавляется по необходимости, раздел 5) │
|
||||
│ 25 (исходящий в интернет) │
|
||||
│ 8080 (панель, за reverse-proxy) │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Взаимодействие панели и Postfix:** панель пишет конфиг-файлы напрямую в локальную ФС (`/etc/postfix/...`) и вызывает `postfix reload` как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами **не нужны**.
|
||||
|
||||
**Milter-цепочка Postfix теперь состоит из двух шагов:** OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в `smtpd_milters`/`non_smtpd_milters`. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника.
|
||||
|
||||
**Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера.** `priority=` в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому:
|
||||
1. `priority`: OpenDKIM стартует первым, затем panel (включая journal-milter listener).
|
||||
2. **Postfix оборачивается стартовым скриптом**, который блокируется в цикле опроса (например, `test -S <unix-сокет>` для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает `postfix start-fg` только после того, как **оба** milter-сокета подтверждённо готовы.
|
||||
3. **Если готовность не наступает в пределах таймаута** — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а.
|
||||
4. Это решает только **проблему холодного старта** (первые секунды жизни контейнера) и не отменяет уже принятое поведение для **runtime**-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента.
|
||||
|
||||
**Требование к supervisord:** если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с `restart: unless-stopped` корректно его перезапустил.
|
||||
|
||||
### 4.1. Доменная модель (ядро продукта)
|
||||
|
||||
SelfPost — **мультидоменный** релей. Две связанные сущности:
|
||||
|
||||
- **Отправляющий домен** — например, `example.com`. Имеет свой DKIM-ключ/селектор.
|
||||
- **Приложение (учётная запись)** — SASL-пара логин/пароль, привязанная к конкретному домену. **У одного домена может быть несколько приложений** (например, `newsletter@example.com` и `alerts@example.com`, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать **только от имени того домена, к которому привязаны**, никогда от имени другого домена.
|
||||
|
||||
**Режим адресов отправки — настройка на уровне приложения**, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов:
|
||||
|
||||
1. **Любой адрес домена** — приложению разрешено указывать в From любой адрес в пределах своего домена (`*@example.com`). Удобно, когда одно приложение шлёт от разных адресов (например, `noreply@`, `alerts@`, `billing@`) без необходимости заводить под каждый отдельное приложение.
|
||||
2. **Список конкретных адресов** — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только `alerts@example.com`). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену.
|
||||
|
||||
В обоих случаях адрес обязан принадлежать домену приложения — выйти за пределы своего домена нельзя ни в одном режиме.
|
||||
|
||||
Для каждого добавленного домена система управляет:
|
||||
|
||||
1. **DKIM-ключ и селектор** — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена.
|
||||
2. **Одно или несколько приложений** — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие.
|
||||
3. **Разрешённые From-адреса** — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1).
|
||||
|
||||
**Жизненный цикл домена (что делает панель при добавлении):**
|
||||
- создаёт запись домена;
|
||||
- генерирует DKIM-ключ + селектор для домена;
|
||||
- показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10);
|
||||
- **не создаёт приложение автоматически** — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже.
|
||||
|
||||
**Жизненный цикл приложения (что делает панель при добавлении приложения к домену):**
|
||||
- генерирует SASL-пару логин/пароль (пароль показывается **один раз**, см. 7.2);
|
||||
- принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения);
|
||||
- регистрирует привязку в `smtpd_sender_login_maps` соответственно режиму — либо wildcard-запись `@example.com → логин`, либо отдельная запись на каждый разрешённый адрес `конкретный-адрес@example.com → логин` (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение);
|
||||
- применяет изменения (`postfix reload`).
|
||||
|
||||
**Изменение режима существующего приложения** — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в `smtpd_sender_login_maps` и `postfix reload`.
|
||||
|
||||
**При удалении домена** — удаляются его DKIM-ключ **и все приложения**, привязанные к этому домену.
|
||||
**При удалении приложения** — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
|
||||
|
||||
Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки.
|
||||
|
||||
---
|
||||
|
||||
## 5. Компонент: Postfix
|
||||
|
||||
Конфигурация Postfix как исходящего релея:
|
||||
|
||||
1. **Приём почты — порт 465 (`smtps`, implicit TLS) как основной.** TLS устанавливается сразу при подключении (wrapper mode, `smtpd_tls_wrappermode = yes` в `master.cf` для сервиса `smtps`), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. **Только для аутентифицированных клиентов (SASL, см. 5.1).** `mynetworks` не используется как способ авторизации отправки — пускают креды, а не сеть. **Категорически недопустима конфигурация open relay.**
|
||||
- **Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости.** Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис `submission` в `master.cf` включается тем же способом, что и `smtps` (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости.
|
||||
2. **Отправка** — напрямую в интернет (без `relayhost`), с MX-lookup и TLS (`smtp_tls_security_level = may` минимум).
|
||||
3. **DKIM** — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6).
|
||||
4. **Ограничения** — `smtpd_recipient_restrictions` запрещает релей для неаутентифицированных (`permit_sasl_authenticated`, `reject_unauth_destination`). Обязательна привязка отправителя к логину (раздел 5.1).
|
||||
5. **Базовый rate-limiting по client IP** — встроенный механизм Postfix (демон `anvil`): `smtpd_client_message_rate_limit` + `anvil_rate_time_unit` ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, **независимо от journal-milter** — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587).
|
||||
6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим `postfix reload`).
|
||||
|
||||
Базовый образ-ориентир для понимания подхода: `boky/postfix` (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера.
|
||||
|
||||
### 5.1. SASL-аутентификация и привязка приложений к домену
|
||||
|
||||
1. **Бэкенд SASL — Cyrus SASL с локальной базой `sasldb2`** (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент `saslpasswd2`), Postfix проверяет их через `smtpd_sasl_auth_enable = yes`.
|
||||
2. **TLS обязателен для аутентификации на обоих портах, но механизм различается:**
|
||||
- На **465 (`smtps`)** — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх `smtpd_tls_wrappermode = yes`.
|
||||
- На **587 (`submission`, если включён)** — `smtpd_tls_auth_only = yes`, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу.
|
||||
В обоих случаях результат одинаков: креды никогда не передаются в открытом виде.
|
||||
3. **Привязка логина к разрешённым адресам (критично).** Настраивается `smtpd_sender_login_maps` + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1):
|
||||
- **режим «любой адрес домена»** — wildcard-запись `@example.com логин` (Postfix поддерживает доменные wildcard-записи в `sender_login_maps`);
|
||||
- **режим «список конкретных адресов»** — отдельная запись на каждый разрешённый адрес: `alerts@example.com логин`, `noreply@example.com логин` и т.д.
|
||||
Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима.
|
||||
4. **Многие-ко-одному.** У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано.
|
||||
|
||||
### 5.2. TLS-сертификаты для Postfix
|
||||
|
||||
**SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов** (ACME/Let's Encrypt). Проект **не** содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые.
|
||||
|
||||
Модель простая:
|
||||
|
||||
1. **Источник** — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost **bind mount'ом, только на чтение** (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8).
|
||||
2. **Потребление** — Postfix настроен читать эти файлы (`smtpd_tls_cert_file` / `smtpd_tls_key_file`) для TLS на 465 (`smtps`, основной), 587 (`submission`, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма.
|
||||
3. **Соответствие hostname** — CN/SAN сертификата должен совпадать с HELO-hostname (`SELFPOST_HOSTNAME`) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy.
|
||||
4. **Обновление** — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через `postfix reload`. Чтобы деплой оставался простым, применяется **периодический `postfix reload` (например, раз в сутки)** — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее.
|
||||
|
||||
Персистентность сертификатов (хранение, ACME-account key) — **ответственность reverse-proxy**, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте.
|
||||
|
||||
---
|
||||
|
||||
## 6. Компонент: OpenDKIM
|
||||
|
||||
DKIM-подпись — **строго per-domain**. Каждый отправляющий домен имеет собственную пару ключей и селектор.
|
||||
|
||||
1. **Генерация при добавлении домена** — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны **переживать перезапуск контейнера** (см. раздел 9).
|
||||
2. **Подпись** — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с `KeyTable` + `SigningTable`, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM.
|
||||
3. **Показ DNS-записи per-domain** — панель показывает публичную часть ключа в формате DNS TXT-записи **для каждого домена отдельно**, чтобы пользователь внёс её в DNS соответствующего домена.
|
||||
4. **Селектор** — на домен; может быть общим значением по умолчанию (например, `selfpost`) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое.
|
||||
5. **При удалении домена** — его ключ и записи в `KeyTable`/`SigningTable` удаляются, OpenDKIM перечитывается.
|
||||
|
||||
---
|
||||
|
||||
## 7. Компонент: Панель управления
|
||||
|
||||
### 7.1. Технологический стек
|
||||
|
||||
- **Язык: Go.** Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка.
|
||||
- **Зависимости — минимальные.** Приоритет стандартной библиотеки: `net/http`, `html/template`, `os/exec`. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из `golang.org/x/crypto`). Каждая внешняя зависимость должна быть обоснована.
|
||||
- HTTP-сервер слушает `:8080` внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10).
|
||||
|
||||
**Front-end:**
|
||||
|
||||
- **Server-rendered** через `html/template`. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль `node_modules`. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки.
|
||||
- **HTMX** — единственная фронтенд-зависимость, подключается одним статическим `<script>` (вендорится в бинарник/образ, без npm). Используется для:
|
||||
- частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
|
||||
- **автообновления экранов мониторинга** (очередь, лог) через периодический polling — `hx-trigger="every Ns"` подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
|
||||
- **Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket.** Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
|
||||
- Эндпоинты, отдающие фрагменты для polling (например, `/queue/fragment`, `/log/fragment`), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.
|
||||
|
||||
### 7.2. Функциональные требования
|
||||
|
||||
Панель предоставляет **ограниченный** набор операций:
|
||||
|
||||
1. **Аутентификация** — вход администратора по логину/паролю (раздел 7.6).
|
||||
2. **Список доменов** — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
|
||||
3. **Добавить домен** — по имени домена панель (см. раздел 4.1):
|
||||
- создаёт запись домена;
|
||||
- генерирует DKIM-ключ + селектор;
|
||||
- применяет изменения (reload OpenDKIM);
|
||||
- показывает DKIM TXT-запись для DNS.
|
||||
Добавление домена **не создаёт приложение** — это отдельное действие (п. 5).
|
||||
4. **Удалить домен** — удаляет DKIM-ключ домена **и все привязанные к нему приложения** (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
|
||||
5. **Добавить приложение к домену** — внутри карточки домена, за одну операцию:
|
||||
- генерирует SASL-пару логин/пароль;
|
||||
- принимает выбор **режима адресов**: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
|
||||
- регистрирует соответствующую(ие) запись(и) в `smtpd_sender_login_maps` (wildcard или по адресам — раздел 5.1);
|
||||
- применяет изменения (`postfix reload`);
|
||||
- **показывает сгенерированный пароль ОДИН РАЗ** (см. 7.6, п. про пароли).
|
||||
6. **Список приложений домена** — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
|
||||
7. **Редактировать режим адресов приложения** — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в `smtpd_sender_login_maps` и применяет изменения.
|
||||
8. **Удалить приложение** — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
|
||||
9. **Перевыпустить пароль приложения** — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
|
||||
10. **Просмотр DKIM DNS-записи** — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
|
||||
11. **Просмотр очереди** — вывод `mailq` / `postqueue -p` в читаемом виде (сколько писем в очереди, статусы). Экран **автообновляется** через HTMX-polling раз в несколько секунд (см. 7.1).
|
||||
12. **Reload** — кнопка, применяющая изменения конфига вручную (`postfix reload` / reload OpenDKIM), если нужно.
|
||||
13. **Просмотр хвоста почтового лога** для диагностики — последние N строк, с автообновлением через polling.
|
||||
14. **Журнал отправки писем** — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
|
||||
15. **Настройка лимитов отправки** — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
|
||||
16. **Скачать полную резервную копию** — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
|
||||
17. **Экспортировать домен** / **Импортировать домен** — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).
|
||||
|
||||
### 7.3. Журнал отправки (Send Log)
|
||||
|
||||
Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой `mailq`/hвост файла, а структурированная, фильтруемая история отправленных писем.
|
||||
|
||||
**Что фиксируется в журнале для каждого письма:**
|
||||
- время приёма;
|
||||
- домен и приложение (SASL-логин), от имени которого отправлено;
|
||||
- From-адрес и To-адрес(а);
|
||||
- Subject (тема);
|
||||
- статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
|
||||
- queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).
|
||||
|
||||
**Технический механизм получения данных:**
|
||||
|
||||
1. **Тема письма не попадает в стандартные логи Postfix** (`mail.log` содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует **лёгкий milter** (например, через библиотеку `github.com/emersion/go-milter` или аналог), подключённый в `smtpd_milters` Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
|
||||
2. **Финальный статус доставки** panel получает отдельно: горутина внутри того же бинарника хвостит `mail.log` (аналогично экрану из п. 13), парсит строки со статусами `sent`/`bounced`/`deferred` по queue-id и обновляет соответствующую запись журнала.
|
||||
3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит **отдельную запись на пару (queue-id, получатель)**, чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.
|
||||
|
||||
> **⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект.** В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:
|
||||
> - **Протокол milter завязан на конкретные версии** (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
|
||||
> - **Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую.** Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от `milter_default_action`) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
|
||||
> - **Меньше протестированных Go-примеров** milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.
|
||||
>
|
||||
> **Требование по снижению риска:** `milter_default_action` для journal-milter должен быть настроен так, чтобы **сбой journal-milter не блокировал приём и отправку почты** (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.
|
||||
|
||||
**Хранение:** таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.
|
||||
|
||||
**Retention (обязательно предусмотреть, иначе журнал растёт бесконечно):** конфигурируемый срок хранения записей (например, `SEND_LOG_RETENTION_DAYS`, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).
|
||||
|
||||
**UI:**
|
||||
- Таблица: время, домен, приложение, From, To, Subject, статус.
|
||||
- **Фильтры — обязательны:** по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с `WHERE`), не на клиенте.
|
||||
- Пагинация или ограничение количества строк на экран (журнал может быть большим).
|
||||
- Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.
|
||||
|
||||
**Приватность и безопасность — важное замечание:** журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.
|
||||
|
||||
### 7.4. Rate limiting (лимиты отправки)
|
||||
|
||||
Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.
|
||||
|
||||
**Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop).** Описан в разделе 5, п. 5: `smtpd_client_message_rate_limit`/`anvil_rate_time_unit`, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.
|
||||
|
||||
**Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели.** Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.
|
||||
|
||||
- **Ключ лимита — client IP, а не SASL-логин.** Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
|
||||
- **Настройка:** при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
|
||||
- **Проверка:** при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и **опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту»** — для видимости в UI.
|
||||
- **Fail-open допустим на этом уровне.** Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.
|
||||
|
||||
> **⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения.** Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.
|
||||
|
||||
> **⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа.** Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.
|
||||
|
||||
### 7.5. Резервное копирование и миграция
|
||||
|
||||
Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.
|
||||
|
||||
**А. Полный бэкап сервера (миграция на новую машину целиком).**
|
||||
|
||||
- **Что входит в архив:** консолидированное персистентное состояние — SQLite (`selfpost.db`: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (`sasldb2`), и **манифест с версией SelfPost**, которой создан бэкап (см. ниже). **Рекомендация исполнителю:** организовать все три под единым корневым путём (например, всё под `/data/`), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
|
||||
- **Что НЕ входит:** TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (`/var/spool/postfix`, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
|
||||
- **Версионирование бэкапа — обязательно.** Архив содержит файл-манифест (например, `manifest.json`) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, `-X main.version=...`, согласуется с тегом Docker-образа). Восстановление **должно происходить в тот же самый Docker-образ той же версии**, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата `sasldb2` между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
|
||||
- **Проверка при восстановлении:** при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — **отказ от запуска/восстановления с понятным сообщением**, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте `selfpost:1.3.0` для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
|
||||
- Эта версия **не про автомиграцию на лету между версиями** — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
|
||||
- **Следствие для деплоя:** `docker-compose.yml` должен использовать **фиксированный тег версии образа**, не `:latest` — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
|
||||
- **Создание бэкапа — двумя равнозначными способами:**
|
||||
1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
|
||||
2. Эквивалентная CLI-утилита внутри контейнера (например, `selfpost-backup`, вызываемая через `docker exec`) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
|
||||
- **Восстановление на новом сервере:** поднять пустой контейнер SelfPost **той же версии образа**, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них **до первого старта** (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
|
||||
- **Прямое следствие для DNS:** поскольку DKIM-ключи переносятся побитово, **DKIM TXT-записи в DNS не нужно менять** после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
|
||||
- **Безопасность архива:** архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.
|
||||
|
||||
**Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).**
|
||||
|
||||
- **Экспорт домена** формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и **соответствующими записями `sasldb2` для приложений этого домена**. Технически это возможно, потому что `sasldb2` (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом `sasldb2` целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
|
||||
- **Импорт на другом экземпляре** восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения **с рабочими паролями без перевыпуска** — по тому же принципу, что и полный бэкап.
|
||||
- **Безопасность экспортного файла — как у полного бэкапа.** Поскольку файл теперь содержит секреты приложений (записи `sasldb2`) и приватный DKIM-ключ домена, он **настолько же чувствителен**, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».
|
||||
|
||||
### 7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)
|
||||
|
||||
Поскольку панель публична, следующее — **не опционально**:
|
||||
|
||||
1. **Первичная инициализация администратора — через одноразовую секретную ссылку**, не через env-переменную с готовым хэшем:
|
||||
- При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида `https://<host>/setup/<token>` в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, `/data/setup-token`) — на случай, если удобнее прочитать файл, чем логи.
|
||||
- **Энтропия токена — не менее 128 бит** (например, 16+ случайных байт из `crypto/rand`, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
|
||||
- **Срок жизни токена — 10 минут** (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к `/setup` (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
|
||||
- **Rate limiting на маршрут `/setup/<token>`** — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
|
||||
- **Сравнение токена — константное по времени** (`crypto/subtle.ConstantTimeCompare` или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
|
||||
- **Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно.** Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
|
||||
- Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
|
||||
- После успешного создания администратора токен **инвалидируется навсегда** (флаг в персистентном состоянии), маршрут `/setup/*` перестаёт быть доступен (404).
|
||||
- Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
|
||||
- `PANEL_USERNAME`/`PANEL_PASSWORD_HASH` как env-переменные **не используются** для основного сценария — см. обновлённый раздел 8.
|
||||
- **SASL-пароли приложений** — отдельная сущность (не путать с паролем администратора панели). Панель **генерирует** сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю **ровно один раз**, и НЕ хранит его в открытом виде для повторного показа (в `sasldb2` он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
|
||||
2. **Валидация ввода на стороне сервера** (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, `.`, `-`, `@`). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: **каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется** (совпадение части после `@` с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит `smtpd_sender_login_maps` уже во время доставки.
|
||||
3. **`postfix reload` и любые вызовы `os/exec`** — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (`exec.Command("postfix", "reload")`), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
|
||||
4. **Запись в конфиг-файлы** — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
|
||||
5. **Rate limiting** на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
|
||||
6. **Сессии** — токен криптографически случайный, cookie с флагами `HttpOnly`, `Secure`, `SameSite`.
|
||||
7. **Экранирование вывода** — данные из очереди/логов (тема письма, адреса) рендерятся через `html/template` с автоэкранированием (защита от XSS).
|
||||
8. **Процесс панели не должен работать от root** (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).
|
||||
|
||||
---
|
||||
|
||||
## 8. Конфигурация (переменные окружения)
|
||||
|
||||
Минимальный набор env-переменных для настройки (исполнитель может расширить):
|
||||
|
||||
- `SELFPOST_HOSTNAME` — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, **не** отправляющий домен — домены добавляются через панель динамически.
|
||||
- `DKIM_SELECTOR_DEFAULT` — селектор DKIM по умолчанию для новых доменов (например, `selfpost`)
|
||||
- `TLS_CERT_FILE` — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
|
||||
- `TLS_KEY_FILE` — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
|
||||
- `SEND_LOG_RETENTION_DAYS` — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
|
||||
- `RATE_LIMIT_MESSAGES_PER_IP` — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
|
||||
- `RATE_LIMIT_WINDOW_SECONDS` — окно времени для базового лимита (`anvil_rate_time_unit`), по умолчанию 3600 (час)
|
||||
|
||||
**Администратор панели не задаётся через env-переменные.** Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через `docker inspect`, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.
|
||||
|
||||
Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже **не** в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).
|
||||
|
||||
---
|
||||
|
||||
## 9. Персистентность (bind mount на хосте)
|
||||
|
||||
Должны переживать перезапуск/пересоздание контейнера:
|
||||
|
||||
- **DKIM-ключи всех доменов** (`/etc/opendkim/keys` + `KeyTable`/`SigningTable`) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
|
||||
- **SASL-база** (`sasldb2`) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
|
||||
- **Конфиги Postfix**, изменяемые панелью: список доменов, карта привязки `smtpd_sender_login_maps` (логины приложений → домены) и т.п.
|
||||
- **База состояния панели — SQLite** (единый файл, например `/data/selfpost.db`), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, **журнал отправки писем** (раздел 7.3) с retention-политикой, и **настройки дифференцированных лимитов отправки** — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
|
||||
- **Очередь Postfix** (`/var/spool/postfix`) — чтобы недоставленные письма не терялись при рестарте.
|
||||
|
||||
**TLS-сертификаты в этот список не входят** — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).
|
||||
|
||||
**Ротация `mail.log` — обязательна.** В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (`SEND_LOG_RETENTION_DAYS`), сырой лог Postfix (`mail.log`, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить `logrotate` внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.
|
||||
|
||||
**Механизм — bind mount на хосте, не именованный Docker volume.** Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, `./data` рядом с `docker-compose.yml`, или зафиксированный абсолютный путь вроде `/opt/selfpost/data`) в консолидированный корень внутри контейнера (тот же `/data`, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через `docker volume inspect`/`docker cp`, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (`tar`, `rsync`, `scp`) без обращения к Docker вообще. `docker-compose.yml` должен использовать синтаксис bind mount (`./data:/data`), а не секцию `volumes:` с именованным томом.
|
||||
|
||||
**Оговорка про прямое копирование данных хостовым `tar` (в обход панели):** риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию **во время работы контейнера** — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования **остановлен**, наивный `tar` полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (`VACUUM INTO`/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой `tar` директории — безопасен, если сервис перед этим остановлен».
|
||||
|
||||
---
|
||||
|
||||
## 10. Развёртывание
|
||||
|
||||
1. Поставка — `Dockerfile` + `docker-compose.yml` + документация.
|
||||
2. **Reverse-proxy обязателен** и является единым источником TLS-сертификатов. Он:
|
||||
- терминирует HTTPS для панели (HTTPS **не** реализуется в коде панели);
|
||||
- выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
|
||||
- поставляет PEM-файлы сертификата в контейнер SelfPost через **bind mount с хоста** (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
|
||||
3. **Проект не привязан к конкретному reverse-proxy.** Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — **Apache**; остальные — как альтернативные фрагменты:
|
||||
- **Apache (httpd)** — *основной сценарий, готовый `docker-compose.yml` из коробки.* Терминация HTTPS для панели через `mod_ssl` + `mod_proxy`/`mod_proxy_http`. Сертификаты — через `certbot` (Apache-плагин) либо встроенный `mod_md`. При использовании certbot PEM-файлы лежат готовыми в `/etc/letsencrypt/live/<hostname>/` на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для `mod_md` показать, как отдать сертификат в PEM в смонтированную директорию.
|
||||
- **nginx** (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
|
||||
- **Caddy** — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в `TLS_CERT_FILE`/`TLS_KEY_FILE`. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю **проверить актуальный путь хранения в текущей версии Caddy**.
|
||||
- **Traefik** — сертификаты в `acme.json`, потребуется шаг извлечения PEM.
|
||||
|
||||
Для каждого варианта показать: связку в `docker-compose.yml` (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (`TLS_CERT_FILE`/`TLS_KEY_FILE`). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.
|
||||
4. При использовании общего hostname для панели и почты это **один сертификат**, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.
|
||||
5. **Рекомендуемый дефолт — Apache** (готовый `docker-compose.yml`, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.
|
||||
6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности `cap_drop`, ограничение доступной ФС.
|
||||
7. Документация должна включать раздел **«Требования к площадке»** — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».
|
||||
8. Документация должна включать раздел **«Настройка DNS»**, явно разделяя записи уровня сервера и уровня домена:
|
||||
- **Уровень сервера (один раз):** PTR/rDNS для IP сервера.
|
||||
- **Уровень домена (для КАЖДОГО добавленного отправляющего домена):** SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
|
||||
9. Документация должна включать раздел **«Прогрев IP»** — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).
|
||||
10. **`docker-compose.yml` должен использовать фиксированный тег версии образа** (например, `selfpost:1.3.0`), не `:latest`. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.
|
||||
11. Документация должна указывать **ориентировочные минимальные требования к машине**: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен `SEND_LOG_RETENTION_DAYS`) и ротируемого `mail.log` (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).
|
||||
|
||||
---
|
||||
|
||||
## 11. Deliverables (что должно быть на выходе)
|
||||
|
||||
1. `Dockerfile` — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (`go build -ldflags "-X main.version=..."`) и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
|
||||
2. `supervisord.conf` — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным `priority=` и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
|
||||
3. Конфигурационные шаблоны Postfix (включая SASL, `smtpd_sender_login_maps`, milter-цепочку `smtpd_milters` с OpenDKIM + journal-milter) и OpenDKIM (`KeyTable`/`SigningTable`).
|
||||
4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
|
||||
5. `docker-compose.yml` — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
|
||||
6. **CLI-утилита резервного копирования** (например, `selfpost-backup`) внутри образа, вызываемая через `docker exec`, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
|
||||
7. `README.md` — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, **процедура полного бэкапа/восстановления и процедура экспорта/импорта домена** (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: **Codeberg — основной, GitHub — зеркало** (настраивается push-зеркалированием средствами Codeberg, без CI/скриптов со стороны проекта).
|
||||
8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
|
||||
9. **`LICENSE` — AGPL-3.0.** Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В `README.md` — явное упоминание лицензии и её смысла в двух-трёх предложениях.
|
||||
|
||||
---
|
||||
|
||||
## 12. Инструкции исполняющему агенту
|
||||
|
||||
1. **Не делать git-коммитов без явной инструкции в промпте.** Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
|
||||
2. После написания Go-кода — обязательно выполнять `go build` и `go vet`; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — `go test`.
|
||||
3. Проверять, что образ собирается (`docker build`) и контейнер стартует, прежде чем считать задачу выполненной.
|
||||
4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
|
||||
5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
|
||||
6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
|
||||
7. **Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ** и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
|
||||
8. **Лицензии внешних Go-зависимостей** (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/mixeme/selfpost
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/emersion/go-milter v0.4.1
|
||||
golang.org/x/crypto v0.54.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
|
||||
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// passwordBytes is the number of random bytes behind a generated application
|
||||
// password. 24 bytes = 192 bits of entropy, well above any brute-force concern
|
||||
// for a SASL credential the panel shows exactly once (security.md).
|
||||
const passwordBytes = 24
|
||||
|
||||
// generatePassword returns a strong, URL-safe random password for an
|
||||
// application's SASL account. The panel generates it, shows it once and never
|
||||
// stores the plaintext (security.md); sasldb2 keeps only the hashed form.
|
||||
//
|
||||
// base64url output keeps the password to a safe ASCII alphabet with no shell or
|
||||
// SMTP-special characters, so it survives being typed into client configuration
|
||||
// and passed to saslpasswd2 over stdin unchanged.
|
||||
func generatePassword() (string, error) {
|
||||
buf := make([]byte, passwordBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||
// for application credentials (architecture.md § Mail path). The panel is the
|
||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||
// created and removed with the standard saslpasswd2 tool ("эквивалент
|
||||
// saslpasswd2", per the plan).
|
||||
type SASLDB struct {
|
||||
path string // sasldb2 file, under /data so it survives restarts
|
||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||
|
||||
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
||||
// the default shells out to the real binary via runSaslpasswd2.
|
||||
run func(args []string, stdin []byte) error
|
||||
|
||||
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
|
||||
// a field so tests can substitute a fake; the default runs db_dump.
|
||||
dump func(path string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
||||
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||
// Postfix's SASL configuration.
|
||||
func NewSASLDB(path, realm string) *SASLDB {
|
||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||
}
|
||||
|
||||
// Set creates or updates an application's SASL account with the given password
|
||||
// (architecture.md § Mail path). Used both at creation and when a password is
|
||||
// regenerated; saslpasswd2 overwrites an existing entry in place.
|
||||
//
|
||||
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
|
||||
// cannot leak through the process table or logs). The login is passed as a
|
||||
// separate argv element after being whitelisted by validateLogin — it never
|
||||
// goes through a shell and is never interpolated into a command string
|
||||
// (security.md).
|
||||
func (s *SASLDB) Set(login, password string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||
// -c: create the account / set the password.
|
||||
// -f: operate on our sasldb2 rather than the system default path.
|
||||
// -u: the realm the account lives under.
|
||||
// --: end of options, so a login can never be parsed as a flag (the
|
||||
// whitelist already forbids nothing that getopt would eat, but a login
|
||||
// starting with '-' is legal there — this keeps it an operand).
|
||||
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
|
||||
if err := s.run(args, []byte(password)); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an application's SASL account (product.md). A missing account
|
||||
// is not treated as an error, so deletion is idempotent and safe to retry.
|
||||
func (s *SASLDB) Delete(login string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// -d: delete the account. "--" as in Set: the login is always an operand.
|
||||
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
|
||||
if err := s.run(args, nil); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
|
||||
// for the login under this realm.
|
||||
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
|
||||
|
||||
// Secret returns an application's stored password so it can be carried in a
|
||||
// domain export and re-created verbatim on another instance (architecture.md §
|
||||
// Persistence). This is possible because sasldb2 keeps the SASL secret in a
|
||||
// password-equivalent form (the plaintext userPassword property, to serve
|
||||
// challenge-response mechanisms) — unlike the admin's one-way bcrypt hash
|
||||
// (security.md). The value is realm-independent, so the importer can re-key it
|
||||
// under its own realm.
|
||||
//
|
||||
// It reads the database with db_dump (Berkeley DB), passing only our own file
|
||||
// path as a fixed argument (no shell, no user input — security.md), and
|
||||
// returns ErrSecretNotFound if the login has no entry.
|
||||
func (s *SASLDB) Secret(login string) (string, error) {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := s.dump(s.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
|
||||
}
|
||||
secret, ok, err := parseSASLSecret(out, login, s.realm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
||||
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
||||
// never silently truncated.
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
inData := false
|
||||
var keyBytes []byte
|
||||
haveKey := false
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !inData {
|
||||
if line == "HEADER=END" {
|
||||
inData = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line == "DATA=END" {
|
||||
break
|
||||
}
|
||||
// Each data line is a single leading space followed by hex.
|
||||
hexStr := strings.TrimPrefix(line, " ")
|
||||
raw, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
|
||||
}
|
||||
if !haveKey {
|
||||
keyBytes = raw
|
||||
haveKey = true
|
||||
continue
|
||||
}
|
||||
// raw is the value for keyBytes.
|
||||
haveKey = false
|
||||
parts := bytes.Split(keyBytes, []byte{0})
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
|
||||
return string(raw), true, nil
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
|
||||
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
|
||||
// ever interpreted as a command (security.md).
|
||||
func runSaslpasswd2(args []string, stdin []byte) error {
|
||||
cmd := exec.Command("saslpasswd2", args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
|
||||
// is our own sasldb2 file (never user input) and is passed as a fixed argument
|
||||
// with no shell (security.md).
|
||||
func dumpSASLDB(path string) ([]byte, error) {
|
||||
cmd := exec.Command("db_dump", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRun struct {
|
||||
args []string
|
||||
stdin string
|
||||
calls int
|
||||
}
|
||||
|
||||
func newFakeSASL() (*SASLDB, *fakeRun) {
|
||||
fr := &fakeRun{}
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
fr.calls++
|
||||
fr.args = args
|
||||
fr.stdin = string(stdin)
|
||||
return nil
|
||||
}
|
||||
return s, fr
|
||||
}
|
||||
|
||||
func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
const secret = "s3cr3t-p4ss"
|
||||
if err := s.Set("alerts", secret); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if fr.stdin != secret {
|
||||
t.Errorf("password not passed on stdin: got %q", fr.stdin)
|
||||
}
|
||||
joined := strings.Join(fr.args, " ")
|
||||
if strings.Contains(joined, secret) {
|
||||
t.Errorf("password leaked into argv: %q", joined)
|
||||
}
|
||||
// Expected fixed flags and the login as its own trailing argument, behind
|
||||
// "--" so it can never be parsed as an option.
|
||||
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
||||
if len(fr.args) != len(want) {
|
||||
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||
}
|
||||
for i := range want {
|
||||
if fr.args[i] != want[i] {
|
||||
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSASLDeleteArgs(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
if err := s.Delete("alerts"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
|
||||
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("delete args = %v, want %v", fr.args, want)
|
||||
}
|
||||
if fr.stdin != "" {
|
||||
t.Errorf("delete should not send stdin, got %q", fr.stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
||||
// same shape `db_dump <sasldb2>` emits.
|
||||
func makeDump(pairs [][2][]byte) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
||||
for _, p := range pairs {
|
||||
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
|
||||
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
|
||||
}
|
||||
b.WriteString("DATA=END\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func saslKey(login, realm, prop string) []byte {
|
||||
return []byte(login + "\x00" + realm + "\x00" + prop)
|
||||
}
|
||||
|
||||
func TestSecretExtractsPassword(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(path string) ([]byte, error) {
|
||||
if path != "/data/sasl/sasldb2" {
|
||||
t.Errorf("dump path = %q", path)
|
||||
}
|
||||
return makeDump([][2][]byte{
|
||||
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
|
||||
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
|
||||
}), nil
|
||||
}
|
||||
got, err := s.Secret("alerts")
|
||||
if err != nil {
|
||||
t.Fatalf("Secret: %v", err)
|
||||
}
|
||||
if got != "hunter2-pass" {
|
||||
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRealmMismatchNotFound(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(string) ([]byte, error) {
|
||||
// Same login but a different realm must not match.
|
||||
return makeDump([][2][]byte{
|
||||
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
|
||||
}), nil
|
||||
}
|
||||
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
|
||||
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMissingLoginNotFound(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(string) ([]byte, error) {
|
||||
return makeDump(nil), nil
|
||||
}
|
||||
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
|
||||
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
called := false
|
||||
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
|
||||
if _, err := s.Secret("bad login"); err == nil {
|
||||
t.Error("Secret accepted invalid login")
|
||||
}
|
||||
if called {
|
||||
t.Error("db_dump invoked for an invalid login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
if err := s.Set("bad login", "pw"); err == nil {
|
||||
t.Error("Set accepted invalid login")
|
||||
}
|
||||
if err := s.Delete("bad@login"); err == nil {
|
||||
t.Error("Delete accepted invalid login")
|
||||
}
|
||||
if fr.calls != 0 {
|
||||
t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package app owns application accounts (product.md § Multi-domain model): the
|
||||
// SASL credentials in sasldb2, the per-application sender address mode, and
|
||||
// the smtpd_sender_login_maps bindings that tie each login to the addresses it
|
||||
// may send from. It keeps those three stores — the SQLite registry, sasldb2
|
||||
// and the Postfix map — in agreement and drives the Postfix reload.
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// SenderMaps is the slice of the Postfix manager the application service needs:
|
||||
// rebuilding the sender_login_maps from the current bindings and reloading.
|
||||
// *postfix.Postfix satisfies it; tests substitute a fake.
|
||||
type SenderMaps interface {
|
||||
RebuildSenderLoginMaps(bindings []postfix.Binding) error
|
||||
}
|
||||
|
||||
// Service coordinates application state across SQLite, sasldb2 and the Postfix
|
||||
// sender_login_maps. Web handlers validate raw input first; the Service performs
|
||||
// the domain-ownership checks that must not be skipped (security.md) and keeps
|
||||
// the stores consistent.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
sasl *SASLDB
|
||||
pf SenderMaps
|
||||
}
|
||||
|
||||
// NewService builds the application service over the shared store, the sasldb2
|
||||
// manager and the Postfix manager.
|
||||
func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
|
||||
return &Service{store: st, sasl: sasl, pf: pf}
|
||||
}
|
||||
|
||||
// List returns a domain's applications with their address lists (product.md).
|
||||
func (s *Service) List(domainID int64) ([]store.Application, error) {
|
||||
return s.store.ListApplicationsByDomain(domainID)
|
||||
}
|
||||
|
||||
// Get returns one application by id (store.ErrApplicationNotFound if absent).
|
||||
func (s *Service) Get(id int64) (store.Application, error) {
|
||||
return s.store.GetApplication(id)
|
||||
}
|
||||
|
||||
// Create adds an application to a domain: it validates the login and (in list
|
||||
// mode) that every address belongs to the domain (security.md), generates a
|
||||
// strong password, writes the SASL account and rebuilds the sender map (spec
|
||||
// 7.2.5). The generated password is returned so the caller can show it exactly
|
||||
// once (security.md) — it is never persisted in plaintext.
|
||||
//
|
||||
// The registry row is written first so its UNIQUE constraint is the sole arbiter
|
||||
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
|
||||
// clobbering an existing account's password in sasldb2). If the SASL write or
|
||||
// the map rebuild fails, everything is rolled back so we never leave an
|
||||
// application the panel cannot fully account for.
|
||||
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
|
||||
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
if err := s.sasl.Set(login, password); err != nil {
|
||||
s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
if err := s.Resync(); err != nil {
|
||||
s.rollbackCreate(a.ID, login)
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
return a, password, nil
|
||||
}
|
||||
|
||||
// rollbackCreate best-effort undoes a partially created application after a
|
||||
// downstream failure: it removes the SASL account (if one was written) and the
|
||||
// registry row. Errors here are subordinate to the original failure the caller
|
||||
// returns.
|
||||
func (s *Service) rollbackCreate(id int64, login string) {
|
||||
if login != "" {
|
||||
_ = s.sasl.Delete(login)
|
||||
}
|
||||
_, _ = s.store.DeleteApplication(id)
|
||||
}
|
||||
|
||||
// ImportApplication re-creates an application from a domain-export file
|
||||
// (architecture.md § Persistence): it validates the login and (in list mode)
|
||||
// that every address belongs to the domain, inserts the registry row and
|
||||
// writes the SASL account with the imported password verbatim, re-keyed under
|
||||
// this instance's realm so the credential keeps working without regeneration.
|
||||
// It deliberately does not rebuild the sender map — the caller (domain import)
|
||||
// does that once after all applications are in — and returns
|
||||
// store.ErrLoginExists if the login collides.
|
||||
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
|
||||
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateImportedPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||
if err != nil {
|
||||
return err // ErrLoginExists surfaces to the caller as a friendly message
|
||||
}
|
||||
if err := s.sasl.Set(login, password); err != nil {
|
||||
_, _ = s.store.DeleteApplication(a.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Secret returns an application's stored password for a domain export
|
||||
// (architecture.md § Persistence). See SASLDB.Secret for why this is possible
|
||||
// and safe.
|
||||
func (s *Service) Secret(login string) (string, error) {
|
||||
return s.sasl.Secret(login)
|
||||
}
|
||||
|
||||
// UpdateMode switches an application's address mode / list and rebuilds the
|
||||
// sender map (product.md). The login and password are untouched. Addresses are
|
||||
// re-validated against the application's domain.
|
||||
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
|
||||
a, err := s.store.GetApplication(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RegeneratePassword issues a fresh password for an existing application (spec
|
||||
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
|
||||
// address mode and bindings are unchanged, so no map rebuild is needed. The new
|
||||
// password is returned to be shown once.
|
||||
func (s *Service) RegeneratePassword(id int64) (string, error) {
|
||||
a, err := s.store.GetApplication(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sasl.Set(a.Login, password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// Delete removes an application: its SASL account, its registry row (and address
|
||||
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
|
||||
// 7.2.8). The domain and other applications are untouched.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
a, err := s.store.DeleteApplication(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sasl.Delete(a.Login); err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop the application's level-2 limit, if any (guide § Rate limiting);
|
||||
// rate_limits has no cascade of its own.
|
||||
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RateLimit returns the application-level differentiated rate limit (guide §
|
||||
// Rate limiting), and whether one is configured, for the application's edit
|
||||
// form.
|
||||
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
|
||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
||||
// is needed.
|
||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||
return s.store.SetRateLimit(store.RateLimit{
|
||||
Scope: store.RateLimitScopeApp,
|
||||
RefID: appID,
|
||||
AllowedIPs: ips,
|
||||
MaxMessages: maxMessages,
|
||||
WindowSeconds: windowSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
||||
// limiting).
|
||||
func (s *Service) ClearRateLimit(appID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
|
||||
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||
// domain. It must be called before the domain's registry rows are cascade-
|
||||
// deleted, while the logins are still known (product.md). The registry rows and
|
||||
// the sender map are handled by the domain deletion path; this only clears
|
||||
// sasldb2, which has no cascade of its own.
|
||||
func (s *Service) PurgeDomainSASL(domainID int64) error {
|
||||
logins, err := s.store.ListLoginsByDomain(domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, login := range logins {
|
||||
if err := s.sasl.Delete(login); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resync rebuilds smtpd_sender_login_maps from the full set of application
|
||||
// bindings and reloads Postfix (architecture.md § Mail path). It is the single
|
||||
// idempotent apply path shared by create/edit/delete and is also reachable
|
||||
// from the manual reload button; it doubles as recovery if the map ever drifts
|
||||
// from the database.
|
||||
func (s *Service) Resync() error {
|
||||
bindings, err := s.store.ListBindings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pfBindings := make([]postfix.Binding, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login})
|
||||
}
|
||||
return s.pf.RebuildSenderLoginMaps(pfBindings)
|
||||
}
|
||||
|
||||
// validateForDomain resolves the domain, validates the login and address mode,
|
||||
// and — in list mode — validates that every address belongs to the domain
|
||||
// (security.md). It returns the cleaned address list, which is empty in wildcard
|
||||
// mode. Resolving the domain here also confirms it exists before any write.
|
||||
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
|
||||
d, err := s.store.GetDomain(domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLogin(login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAddressMode(mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == store.AddressModeWildcard {
|
||||
return nil, nil
|
||||
}
|
||||
return parseAddresses(rawAddresses, d.Name)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// fakeMaps records the last set of bindings passed to a rebuild and can be told
|
||||
// to fail, so we can exercise the rollback paths.
|
||||
type fakeMaps struct {
|
||||
last []postfix.Binding
|
||||
calls int
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (f *fakeMaps) RebuildSenderLoginMaps(b []postfix.Binding) error {
|
||||
f.calls++
|
||||
if f.failNext {
|
||||
f.failNext = false
|
||||
return errors.New("boom")
|
||||
}
|
||||
f.last = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// saslRecorder is a fake sasldb2 backend recording set/delete calls.
|
||||
type saslRecorder struct {
|
||||
set map[string]string // login -> password
|
||||
deleted []string
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fakeMaps) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
rec := &saslRecorder{set: map[string]string{}}
|
||||
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
sasl.run = func(args []string, stdin []byte) error {
|
||||
if rec.failNext {
|
||||
rec.failNext = false
|
||||
return errors.New("saslpasswd2 failed")
|
||||
}
|
||||
// args end with the login; a "-d" anywhere means delete.
|
||||
login := args[len(args)-1]
|
||||
del := false
|
||||
for _, a := range args {
|
||||
if a == "-d" {
|
||||
del = true
|
||||
}
|
||||
}
|
||||
if del {
|
||||
rec.deleted = append(rec.deleted, login)
|
||||
delete(rec.set, login)
|
||||
} else {
|
||||
rec.set[login] = string(stdin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
maps := &fakeMaps{}
|
||||
return NewService(st, sasl, maps), st, rec, maps
|
||||
}
|
||||
|
||||
func addDomain(t *testing.T, st *store.Store, name string) store.Domain {
|
||||
t.Helper()
|
||||
d, err := st.AddDomain(name, "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestServiceCreateWildcard(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
a, pw, err := svc.Create(d.ID, "alerts", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if a.Login != "alerts" {
|
||||
t.Errorf("login = %q", a.Login)
|
||||
}
|
||||
if rec.set["alerts"] != pw {
|
||||
t.Errorf("sasl password %q != returned %q", rec.set["alerts"], pw)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Address != "@example.com" || maps.last[0].Login != "alerts" {
|
||||
t.Errorf("map bindings = %+v", maps.last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateListValidatesDomain(t *testing.T) {
|
||||
svc, st, _, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
// A cross-domain address is rejected before anything is written.
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeList, []string{"a@evil.com"})
|
||||
if err == nil {
|
||||
t.Fatal("Create accepted cross-domain address")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("application persisted despite validation failure: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateDuplicateLogin(t *testing.T) {
|
||||
svc, st, _, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
if _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil)
|
||||
if !errors.Is(err, store.ErrLoginExists) {
|
||||
t.Fatalf("duplicate create = %v, want ErrLoginExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateRollsBackOnSASLFailure(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
rec.failNext = true // saslpasswd2 fails on the first (set) call
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Create to fail when SASL set fails")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("registry row not rolled back: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateRollsBackOnMapFailure(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
maps.failNext = true
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Create to fail when map rebuild fails")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("registry row not rolled back: %+v", apps)
|
||||
}
|
||||
if _, ok := rec.set["app1"]; ok {
|
||||
t.Errorf("SASL account not rolled back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDelete(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(a.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, ok := rec.set["app1"]; ok {
|
||||
t.Error("SASL account not deleted")
|
||||
}
|
||||
if len(rec.deleted) != 1 || rec.deleted[0] != "app1" {
|
||||
t.Errorf("deleted logins = %v", rec.deleted)
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("application not deleted: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateMode(t *testing.T) {
|
||||
svc, st, _, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil {
|
||||
t.Fatalf("UpdateMode: %v", err)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
|
||||
t.Errorf("map after mode switch = %+v", maps.last)
|
||||
}
|
||||
got, _ := st.GetApplication(a.ID)
|
||||
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 {
|
||||
t.Errorf("stored app after switch = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRegeneratePassword(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, pw1, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pw2, err := svc.RegeneratePassword(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RegeneratePassword: %v", err)
|
||||
}
|
||||
if pw1 == pw2 {
|
||||
t.Error("regenerated password equals the old one")
|
||||
}
|
||||
if rec.set["app1"] != pw2 {
|
||||
t.Errorf("sasl password not updated to new value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
|
||||
[]string{"a@example.com"}, "imported-pw"); err != nil {
|
||||
t.Fatalf("ImportApplication: %v", err)
|
||||
}
|
||||
// Registry row and SASL account written with the imported password verbatim.
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 1 || apps[0].Login != "mailer" {
|
||||
t.Fatalf("apps = %+v", apps)
|
||||
}
|
||||
if rec.set["mailer"] != "imported-pw" {
|
||||
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
|
||||
}
|
||||
// Import does not rebuild the sender map itself (the caller batches that).
|
||||
if maps.calls != 0 {
|
||||
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportApplicationRejectsBadInput(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
// Empty password.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
|
||||
t.Error("accepted empty imported password")
|
||||
}
|
||||
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
|
||||
t.Error("accepted password with control characters")
|
||||
}
|
||||
// Cross-domain address.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
|
||||
t.Error("accepted cross-domain address")
|
||||
}
|
||||
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
|
||||
t.Errorf("rows persisted despite validation failure: %+v", apps)
|
||||
}
|
||||
if len(rec.set) != 0 {
|
||||
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePurgeDomainSASL(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
if _, _, err := svc.Create(d.ID, "app-a", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Create(d.ID, "app-b", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.PurgeDomainSASL(d.ID); err != nil {
|
||||
t.Fatalf("PurgeDomainSASL: %v", err)
|
||||
}
|
||||
if len(rec.set) != 0 {
|
||||
t.Errorf("SASL accounts remain after purge: %v", rec.set)
|
||||
}
|
||||
if len(rec.deleted) != 2 {
|
||||
t.Errorf("deleted %d logins, want 2", len(rec.deleted))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
minLoginLen = 3
|
||||
maxLoginLen = 64
|
||||
)
|
||||
|
||||
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
||||
// where '@' separates the user from the realm, so allowing it would change the
|
||||
// account's identity. Client validation is never trusted.
|
||||
//
|
||||
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
||||
// command argument (never through a shell, security.md); this whitelist is what
|
||||
// makes that safe.
|
||||
func validateLogin(login string) error {
|
||||
if len(login) < minLoginLen || len(login) > maxLoginLen {
|
||||
return fmt.Errorf("login must be %d-%d characters", minLoginLen, maxLoginLen)
|
||||
}
|
||||
for _, r := range login {
|
||||
lower := r >= 'a' && r <= 'z'
|
||||
upper := r >= 'A' && r <= 'Z'
|
||||
digit := r >= '0' && r <= '9'
|
||||
if !lower && !upper && !digit && r != '.' && r != '-' && r != '_' {
|
||||
return fmt.Errorf("login may contain only letters, digits, '.', '-' and '_'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateImportedPassword guards a password taken from a domain-export file
|
||||
// (architecture.md § Persistence) before it is written to sasldb2. Our own
|
||||
// exports carry base64url passwords, but the file is untrusted input, so we
|
||||
// reject an empty value or one containing control characters — saslpasswd2
|
||||
// reads the passphrase from stdin and a newline would silently truncate it
|
||||
// (security.md).
|
||||
func validateImportedPassword(password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("imported application password is empty")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("imported application password is too long")
|
||||
}
|
||||
for _, r := range password {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("imported application password contains control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAddressMode checks the submitted mode is one of the two known values.
|
||||
func validateAddressMode(mode string) error {
|
||||
if mode != store.AddressModeWildcard && mode != store.AddressModeList {
|
||||
return fmt.Errorf("invalid address mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeAddress lower-cases and trims a sender address. Both the local part
|
||||
// and domain are treated case-insensitively for the ownership check and for the
|
||||
// generated map, matching how addresses are compared elsewhere.
|
||||
func normalizeAddress(addr string) string {
|
||||
return strings.ToLower(strings.TrimSpace(addr))
|
||||
}
|
||||
|
||||
// validateSenderAddress enforces that a list-mode address is well-formed and,
|
||||
// critically, belongs to the application's own domain (security.md). The domain
|
||||
// check is done here, before anything is written to a config file — not left to
|
||||
// smtpd_sender_login_maps to catch at delivery time. domain must already be a
|
||||
// validated, normalised domain name.
|
||||
func validateSenderAddress(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at < 0 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if err := validateLocalPart(local); err != nil {
|
||||
return fmt.Errorf("%q: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLocalPart applies a conservative whitelist to the part before '@'.
|
||||
// This is deliberately stricter than RFC 5321 (no quoted local parts) so the
|
||||
// value is always safe to write verbatim into the Postfix map (security.md).
|
||||
func validateLocalPart(local string) error {
|
||||
if local == "" {
|
||||
return fmt.Errorf("missing the part before '@'")
|
||||
}
|
||||
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("local part must not start or end with '.'")
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAddresses normalises, validates and de-duplicates a list of submitted
|
||||
// sender addresses for a list-mode application. It requires at least one address
|
||||
// and that each belongs to domain. The returned slice is de-duplicated but keeps
|
||||
// submission order stable for display; the store sorts on read.
|
||||
func parseAddresses(raw []string, domain string) ([]string, error) {
|
||||
seen := make(map[string]bool)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
addr := normalizeAddress(r)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSenderAddress(addr, domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("list mode requires at least one address")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateLogin(t *testing.T) {
|
||||
good := []string{"alerts", "prod-server", "app_1", "News.Letter"}
|
||||
for _, l := range good {
|
||||
if err := validateLogin(l); err != nil {
|
||||
t.Errorf("validateLogin(%q) = %v, want nil", l, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"ab", // too short
|
||||
"alerts@example.com", // '@' not allowed (sasldb realm separator)
|
||||
"has space", // whitespace
|
||||
"inject\nline", // newline
|
||||
"comma,login", // map value separator
|
||||
"colon:login", // config separator
|
||||
}
|
||||
for _, l := range bad {
|
||||
if err := validateLogin(l); err == nil {
|
||||
t.Errorf("validateLogin(%q) = nil, want error", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSenderAddressDomainOwnership(t *testing.T) {
|
||||
// The critical check (security.md): an address must belong to the app's domain.
|
||||
if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil {
|
||||
t.Errorf("same-domain address rejected: %v", err)
|
||||
}
|
||||
if err := validateSenderAddress("alerts@evil.com", "example.com"); err == nil {
|
||||
t.Error("cross-domain address accepted, want rejection")
|
||||
}
|
||||
// A trailing-domain trick must not pass as ownership.
|
||||
if err := validateSenderAddress("a@notexample.com", "example.com"); err == nil {
|
||||
t.Error("suffix-domain address accepted, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSenderAddressForm(t *testing.T) {
|
||||
bad := []string{
|
||||
"noat.example.com", // no '@'
|
||||
"@example.com", // empty local part
|
||||
".dot@example.com", // leading dot
|
||||
"dot.@example.com", // trailing dot
|
||||
"in ject@example.com", // space
|
||||
"quote\"@example.com", // disallowed char
|
||||
}
|
||||
for _, a := range bad {
|
||||
if err := validateSenderAddress(a, "example.com"); err == nil {
|
||||
t.Errorf("validateSenderAddress(%q) = nil, want error", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddresses(t *testing.T) {
|
||||
// Normalises case, trims, drops blanks, de-duplicates.
|
||||
got, err := parseAddresses([]string{" Alerts@Example.com ", "", "noreply@example.com", "alerts@example.com"}, "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("parseAddresses: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "alerts@example.com" || got[1] != "noreply@example.com" {
|
||||
t.Fatalf("parseAddresses = %v", got)
|
||||
}
|
||||
|
||||
// Empty list in list mode is an error.
|
||||
if _, err := parseAddresses([]string{"", " "}, "example.com"); err == nil {
|
||||
t.Error("empty address list accepted, want error")
|
||||
}
|
||||
// A cross-domain address rejects the whole submission.
|
||||
if _, err := parseAddresses([]string{"ok@example.com", "bad@other.com"}, "example.com"); err == nil {
|
||||
t.Error("cross-domain address in list accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePasswordStrength(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
p, err := generatePassword()
|
||||
if err != nil {
|
||||
t.Fatalf("generatePassword: %v", err)
|
||||
}
|
||||
if len(p) < 30 {
|
||||
t.Fatalf("password too short: %d chars", len(p))
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatalf("duplicate password generated: %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package backup implements SelfPost's full-server backup and the restore
|
||||
// version guard (architecture.md § Persistence). A full backup is a
|
||||
// gzip-compressed tar of the consolidated persistent state under /data — the
|
||||
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
|
||||
// SASL database — plus a manifest recording the SelfPost version that produced
|
||||
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
|
||||
// queue are deliberately excluded (architecture.md § Persistence).
|
||||
//
|
||||
// Restore is intentionally not a separate code path: a backup is extracted
|
||||
// into the /data bind mount before first start, and the panel regenerates
|
||||
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal
|
||||
// start. The only restore-specific step is CheckRestore, which refuses to boot
|
||||
// if the manifest's version does not match the running binary, so
|
||||
// schema/format skew between versions cannot silently corrupt state
|
||||
// (architecture.md § Persistence).
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver, for the VACUUM INTO snapshot
|
||||
)
|
||||
|
||||
// FormatFull identifies a full-server backup manifest.
|
||||
const FormatFull = "selfpost-full-backup"
|
||||
|
||||
// ManifestName is the manifest's filename, both inside the archive and, after a
|
||||
// restore extraction, at the root of the data directory where CheckRestore
|
||||
// looks for it.
|
||||
const ManifestName = "manifest.json"
|
||||
|
||||
// Manifest is the small JSON document embedded in every backup archive. Its
|
||||
// Version is the single fact that makes restore safe: the panel refuses to
|
||||
// boot a data directory whose manifest version does not match its own binary
|
||||
// (architecture.md § Persistence).
|
||||
type Manifest struct {
|
||||
Format string `json:"format"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Params configures a backup. DataDir is the consolidated state root (/data);
|
||||
// DBPath is the live SQLite file within it, snapshotted consistently rather than
|
||||
// copied byte-for-byte while it may be mid-write; Version is stamped into the
|
||||
// manifest.
|
||||
type Params struct {
|
||||
DataDir string
|
||||
DBPath string
|
||||
Version string
|
||||
}
|
||||
|
||||
// excludedFromArchive lists the data-directory entries a backup never carries.
|
||||
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
||||
// written under the canonical name; the setup token is transient bootstrap
|
||||
// state; a stale manifest from a previous restore must not be re-captured (a
|
||||
// fresh one is written instead); a "tls" directory holds the reverse proxy's
|
||||
// certificates, which are explicitly out of scope for a SelfPost backup
|
||||
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
|
||||
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
|
||||
// delivery log plus its fourteen rotated files, which is diagnostic output, not
|
||||
// state to restore, and by far the largest thing under /data.
|
||||
var excludedFromArchive = map[string]bool{
|
||||
"selfpost.db": true,
|
||||
"selfpost.db-wal": true,
|
||||
"selfpost.db-shm": true,
|
||||
"selfpost.db-journal": true,
|
||||
"setup-token": true,
|
||||
"tls": true,
|
||||
"log": true,
|
||||
ManifestName: true,
|
||||
}
|
||||
|
||||
// Create writes a gzip-compressed tar backup to w. Archive entries are named
|
||||
// relative to DataDir, so extracting the archive into the /data bind mount
|
||||
// reconstructs the state in place (architecture.md § Persistence). The SQLite
|
||||
// database is added as a consistent snapshot under "selfpost.db"; everything
|
||||
// else under DataDir is copied as-is except the entries in
|
||||
// excludedFromArchive.
|
||||
func Create(w io.Writer, p Params) error {
|
||||
if p.DataDir == "" || p.DBPath == "" {
|
||||
return fmt.Errorf("backup: DataDir and DBPath are required")
|
||||
}
|
||||
|
||||
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
manifest := Manifest{
|
||||
Format: FormatFull,
|
||||
Version: p.Version,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: encode manifest: %w", err)
|
||||
}
|
||||
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The consistent SQLite snapshot, under the canonical filename the panel
|
||||
// opens on start (the live file and its WAL/SHM are excluded from the walk).
|
||||
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addTree(tw, p.DataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("backup: close tar: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return fmt.Errorf("backup: close gzip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addTree walks dataDir and adds every regular file (and directory, to preserve
|
||||
// empty ones and modes) to tw under its path relative to dataDir, skipping the
|
||||
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
|
||||
// skipped: /data holds none in normal operation, and copying them into a backup
|
||||
// would be meaningless or unsafe.
|
||||
func addTree(tw *tar.Writer, dataDir string) error {
|
||||
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(dataDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil // the data root itself is implicit
|
||||
}
|
||||
// filepath.Rel yields OS separators; tar names use forward slashes.
|
||||
name := filepath.ToSlash(rel)
|
||||
// Exclude by top-level name (the live DB, setup token and stale manifest
|
||||
// all live at the data root).
|
||||
if excludedFromArchive[name] {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case d.IsDir():
|
||||
hdr := &tar.Header{
|
||||
Typeflag: tar.TypeDir,
|
||||
Name: name + "/",
|
||||
Mode: int64(info.Mode().Perm()),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
return tw.WriteHeader(hdr)
|
||||
case info.Mode().IsRegular():
|
||||
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||
default:
|
||||
return nil // skip symlinks/sockets/devices
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// writeTarBytes writes an in-memory file entry.
|
||||
func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
|
||||
hdr := &tar.Header{
|
||||
Typeflag: tar.TypeReg,
|
||||
Name: name,
|
||||
Mode: mode,
|
||||
Size: int64(len(data)),
|
||||
ModTime: time.Now().UTC(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("backup: write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTarFile streams a file from disk into the archive under name.
|
||||
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: open %s: %w", srcPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: stat %s: %w", srcPath, err)
|
||||
}
|
||||
hdr := &tar.Header{
|
||||
Typeflag: tar.TypeReg,
|
||||
Name: name,
|
||||
Mode: int64(mode.Perm()),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("backup: write header %s: %w", name, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return fmt.Errorf("backup: copy %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
|
||||
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
|
||||
// the panel is writing to the live file under WAL. It returns the snapshot path
|
||||
// and a cleanup function the caller must defer.
|
||||
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
|
||||
}
|
||||
cleanup = func() { _ = os.RemoveAll(dir) }
|
||||
target := filepath.Join(dir, "selfpost.db")
|
||||
|
||||
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
|
||||
// failing immediately if the panel happens to be mid-write.
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("backup: open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// VACUUM INTO takes a string literal, not a bound parameter. target is a path
|
||||
// we generated (never user input); single quotes are doubled defensively.
|
||||
stmt := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("backup: snapshot database: %w", err)
|
||||
}
|
||||
return target, cleanup, nil
|
||||
}
|
||||
|
||||
// CheckRestore enforces the backup version guard (architecture.md §
|
||||
// Persistence). If manifestPath exists (a backup was extracted into the data
|
||||
// directory), its version must match binaryVersion or the panel refuses to
|
||||
// start, telling the operator which image tag to use. On a match the manifest
|
||||
// is consumed (deleted) so it guards only the first boot after a restore and
|
||||
// never blocks a later in-place image upgrade. Absence of the manifest is the
|
||||
// normal case and returns nil.
|
||||
func CheckRestore(manifestPath, binaryVersion string) error {
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil // ordinary start, not a restore
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: read restore manifest: %w", err)
|
||||
}
|
||||
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
|
||||
}
|
||||
if m.Format != FormatFull {
|
||||
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
|
||||
}
|
||||
if m.Version != binaryVersion {
|
||||
return fmt.Errorf(
|
||||
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
|
||||
m.Version, binaryVersion, m.Version)
|
||||
}
|
||||
// Version matches: consume the manifest so subsequent normal starts (and
|
||||
// in-place upgrades) are not gated by it.
|
||||
if err := os.Remove(manifestPath); err != nil {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
||||
// DKIM key, SASL and transient files a backup must include or exclude.
|
||||
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
||||
t.Helper()
|
||||
dataDir = t.TempDir()
|
||||
dbPath = filepath.Join(dataDir, "selfpost.db")
|
||||
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
||||
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||
// Transient files that must NOT be archived.
|
||||
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
|
||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
|
||||
// Postfix's delivery log and its rotated files: diagnostic output, not
|
||||
// state, and the bulkiest thing under /data.
|
||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
|
||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
||||
return dataDir, dbPath
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// readArchive returns the set of regular-file entries (name -> content) in a
|
||||
// gzip tar produced by Create.
|
||||
func readArchive(t *testing.T, data []byte) map[string]string {
|
||||
t.Helper()
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
out := map[string]string{}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar next: %v", err)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatalf("tar read %s: %v", hdr.Name, err)
|
||||
}
|
||||
out[hdr.Name] = string(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||
dataDir, dbPath := seedDataDir(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
files := readArchive(t, buf.Bytes())
|
||||
|
||||
// Present.
|
||||
for _, name := range []string{
|
||||
ManifestName,
|
||||
"selfpost.db",
|
||||
"opendkim/keys/example.com/selfpost.private",
|
||||
"sasl/sasldb2",
|
||||
"postfix/sender_login_maps",
|
||||
} {
|
||||
if _, ok := files[name]; !ok {
|
||||
t.Errorf("archive missing %s", name)
|
||||
}
|
||||
}
|
||||
// Excluded.
|
||||
for _, name := range []string{
|
||||
"setup-token", "selfpost.db-wal", "selfpost.db-shm",
|
||||
"log/mail.log", "log/mail.log.1",
|
||||
} {
|
||||
if _, ok := files[name]; ok {
|
||||
t.Errorf("archive should not contain %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Manifest is well-formed and carries the version.
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
|
||||
t.Fatalf("manifest json: %v", err)
|
||||
}
|
||||
if m.Format != FormatFull || m.Version != "1.2.3" {
|
||||
t.Errorf("manifest = %+v, want format=%s version=1.2.3", m, FormatFull)
|
||||
}
|
||||
|
||||
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
||||
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
||||
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
st, err := store.Open(snapPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open snapshot: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
domains, err := st.ListDomains()
|
||||
if err != nil {
|
||||
t.Fatalf("list domains from snapshot: %v", err)
|
||||
}
|
||||
if len(domains) != 1 || domains[0].Name != "example.com" {
|
||||
t.Errorf("snapshot domains = %+v, want one example.com", domains)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, format, version string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, ManifestName)
|
||||
b, _ := json.Marshal(Manifest{Format: format, Version: version, CreatedAt: "now"})
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
|
||||
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil {
|
||||
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||
if err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatalf("CheckRestore matching = %v, want nil", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||
err := CheckRestore(path, "2.0.0")
|
||||
if err == nil {
|
||||
t.Fatal("CheckRestore mismatch = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
|
||||
t.Errorf("error should name both versions: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Errorf("manifest must be kept on mismatch so the operator can switch images: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeManifest(t, dir, "something-else", "1.0.0")
|
||||
if err := CheckRestore(path, "1.0.0"); err == nil {
|
||||
t.Error("CheckRestore accepted a non-backup manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package buildinfo exposes build-time metadata stamped via -ldflags.
|
||||
package buildinfo
|
||||
|
||||
// Version is the SelfPost release. It is set at build time with
|
||||
//
|
||||
// -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=<tag>"
|
||||
//
|
||||
// and must match the Docker image tag; it is used for the backup/restore
|
||||
// compatibility check (architecture.md § Persistence). Defaults to "dev" for local/unstamped builds.
|
||||
var Version = "dev"
|
||||
@@ -0,0 +1,234 @@
|
||||
// Package dnscheck performs the read-only DNS lookups behind the panel's
|
||||
// deliverability checks: forward-confirmed reverse DNS (FCrDNS) for the
|
||||
// server's own hostname, and the DKIM/SPF/DMARC records published for each
|
||||
// sending domain.
|
||||
//
|
||||
// Every lookup is bounded by a timeout and results are cached, because DNS is
|
||||
// the one part of the status page that talks to the network: a slow or dead
|
||||
// resolver must degrade a single card to "could not check", never hang the
|
||||
// page. Nothing here changes state — the panel only reports what the world can
|
||||
// see about this server.
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
const (
|
||||
// lookupTimeout bounds all the lookups of a single check together, so a
|
||||
// dead resolver costs one wait and not one per record type.
|
||||
lookupTimeout = 5 * time.Second
|
||||
// serverTTL/domainTTL are how long a cached result stays fresh. The
|
||||
// server's own hostname/PTR is cheap and rarely changes; a domain's
|
||||
// records are three lookups, and the operator has just published them, so
|
||||
// a few minutes plus an explicit Re-check button is the right trade.
|
||||
serverTTL = time.Minute
|
||||
domainTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Result is the outcome of one published-record check.
|
||||
type Result struct {
|
||||
Status health.Status
|
||||
// Detail is a full sentence for the operator: what was found and, when
|
||||
// something is wrong, what to do about it.
|
||||
Detail string
|
||||
// Records is what was actually found in DNS, shown verbatim so the
|
||||
// operator can compare it with what they published.
|
||||
Records []string
|
||||
}
|
||||
|
||||
// Server is the state of the server's own name in DNS: the addresses
|
||||
// SELFPOST_HOSTNAME resolves to, and whether their PTR records point back at
|
||||
// it. Receiving servers weigh this heavily, so a mismatch is an error.
|
||||
type Server struct {
|
||||
Hostname string
|
||||
IPs []string // forward-resolved addresses, reused for the SPF check
|
||||
PTR Result
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// Domain is the published-DNS state of one sending domain.
|
||||
type Domain struct {
|
||||
Name string
|
||||
DKIM Result
|
||||
SPF Result
|
||||
DMARC Result
|
||||
Overall health.Status
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
||||
// tells the operator to publish (domain.DKIMRecord.Value), so the check
|
||||
// compares DNS against the key this server actually signs with. Hostname and
|
||||
// ServerIPs identify this server and come from a preceding Server check.
|
||||
type Query struct {
|
||||
Name string
|
||||
Selector string
|
||||
ExpectedDKIM string
|
||||
Hostname string
|
||||
ServerIPs []string
|
||||
}
|
||||
|
||||
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||
// tests can drive the checks without touching the network.
|
||||
type resolver interface {
|
||||
LookupTXT(ctx context.Context, name string) ([]string, error)
|
||||
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
|
||||
}
|
||||
|
||||
// Checker runs the checks and caches their results. The zero value is not
|
||||
// usable; call New.
|
||||
type Checker struct {
|
||||
resolver resolver
|
||||
timeout time.Duration
|
||||
serverTTL time.Duration
|
||||
domainTTL time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
servers map[string]cached[Server]
|
||||
domains map[string]cached[Domain]
|
||||
}
|
||||
|
||||
type cached[T any] struct {
|
||||
value T
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// New returns a Checker querying the given recursive resolvers (empty means
|
||||
// DefaultResolvers) with the package's default timeout and cache lifetimes.
|
||||
// The lookups deliberately bypass the system resolver — see externalResolver.
|
||||
func New(resolvers []string) *Checker {
|
||||
return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL)
|
||||
}
|
||||
|
||||
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
||||
return &Checker{
|
||||
resolver: r,
|
||||
timeout: timeout,
|
||||
serverTTL: srvTTL,
|
||||
domainTTL: domTTL,
|
||||
servers: make(map[string]cached[Server]),
|
||||
domains: make(map[string]cached[Domain]),
|
||||
}
|
||||
}
|
||||
|
||||
// Server checks the server's own hostname. force skips the cache, for the
|
||||
// Re-check button.
|
||||
func (c *Checker) Server(hostname string, force bool) Server {
|
||||
if !force {
|
||||
c.mu.Lock()
|
||||
entry, ok := c.servers[hostname]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Now().Before(entry.expires) {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
defer cancel()
|
||||
srv := c.checkServer(ctx, hostname)
|
||||
|
||||
c.mu.Lock()
|
||||
c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
|
||||
c.mu.Unlock()
|
||||
return srv
|
||||
}
|
||||
|
||||
// Domain checks one sending domain's published records. force skips the cache,
|
||||
// for the Re-check button on the domain page.
|
||||
func (c *Checker) Domain(q Query, force bool) Domain {
|
||||
if !force {
|
||||
c.mu.Lock()
|
||||
entry, ok := c.domains[q.Name]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Now().Before(entry.expires) {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
defer cancel()
|
||||
d := c.checkDomain(ctx, q)
|
||||
|
||||
c.mu.Lock()
|
||||
c.domains[q.Name] = cached[Domain]{value: d, expires: d.CheckedAt.Add(c.domainTTL)}
|
||||
c.mu.Unlock()
|
||||
return d
|
||||
}
|
||||
|
||||
// Forget drops a domain's cached result, so the next page view re-checks it.
|
||||
// Used when a domain is removed or re-imported.
|
||||
func (c *Checker) Forget(domainName string) {
|
||||
c.mu.Lock()
|
||||
delete(c.domains, domainName)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// checkDomain runs the three record checks concurrently: they are independent,
|
||||
// and in series three timeouts would stack up into a page that looks hung.
|
||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
||||
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
|
||||
wg.Wait()
|
||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
|
||||
return d
|
||||
}
|
||||
|
||||
// lookupTXT wraps the resolver's TXT lookup, separating "the name does not
|
||||
// exist / has no TXT records" (a finding to report) from "the lookup failed"
|
||||
// (nothing was learned).
|
||||
func (c *Checker) lookupTXT(ctx context.Context, name string) (records []string, found bool, err error) {
|
||||
txt, err := c.resolver.LookupTXT(ctx, name)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if len(txt) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return txt, true, nil
|
||||
}
|
||||
|
||||
// lookupFailed is the shared shape for "the resolver did not answer": unknown,
|
||||
// not an accusation against the domain's configuration.
|
||||
func lookupFailed(what string, err error) Result {
|
||||
return Result{
|
||||
Status: health.StatusUnknown,
|
||||
Detail: "Could not check " + what + ": the DNS lookup failed (" + dnsErrorText(err) + "). Try Re-check in a moment.",
|
||||
}
|
||||
}
|
||||
|
||||
// dnsErrorText reduces a resolver error to its message, without the internals
|
||||
// (Go wraps the name and server into the string form).
|
||||
func dnsErrorText(err error) string {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsTimeout {
|
||||
return "timed out"
|
||||
}
|
||||
return dnsErr.Err
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// normalizeName lowercases a DNS name and drops the root label, so a PTR answer
|
||||
// ("mail.example.com.") compares equal to a configured hostname.
|
||||
func normalizeName(name string) string {
|
||||
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// fakeResolver serves a fixed zone, so the checks can be driven through every
|
||||
// branch without touching the network. An absent name resolves to the same
|
||||
// "not found" DNSError the standard resolver returns for NXDOMAIN.
|
||||
type fakeResolver struct {
|
||||
txt map[string][]string
|
||||
addr map[string][]net.IPAddr
|
||||
ptr map[string][]string
|
||||
mx map[string][]*net.MX
|
||||
|
||||
// fail names that must return a transient failure instead of an answer.
|
||||
fail map[string]bool
|
||||
// lookups counts every query, for the cache tests.
|
||||
lookups int
|
||||
}
|
||||
|
||||
func notFound(name string) error {
|
||||
return &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||
}
|
||||
|
||||
func (f *fakeResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
|
||||
f.lookups++
|
||||
if f.fail[name] {
|
||||
return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
|
||||
}
|
||||
if v, ok := f.txt[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, notFound(name)
|
||||
}
|
||||
|
||||
func (f *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
|
||||
f.lookups++
|
||||
if v, ok := f.addr[host]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, notFound(host)
|
||||
}
|
||||
|
||||
func (f *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
f.lookups++
|
||||
if v, ok := f.ptr[addr]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, notFound(addr)
|
||||
}
|
||||
|
||||
func (f *fakeResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
|
||||
f.lookups++
|
||||
if v, ok := f.mx[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, notFound(name)
|
||||
}
|
||||
|
||||
func ipAddrs(ips ...string) []net.IPAddr {
|
||||
out := make([]net.IPAddr, 0, len(ips))
|
||||
for _, s := range ips {
|
||||
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newTestChecker(f *fakeResolver) *Checker {
|
||||
return newChecker(f, time.Second, time.Minute, time.Minute)
|
||||
}
|
||||
|
||||
func TestServerPTRMatches(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusOK {
|
||||
t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 1 || srv.IPs[0] != "203.0.113.10" {
|
||||
t.Errorf("IPs = %v, want the forward-resolved address for the SPF check", srv.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPTRMismatchIsAnError(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"static-10.provider.net."}},
|
||||
}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusError {
|
||||
t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.PTR.Records) != 1 || !strings.Contains(srv.PTR.Records[0], "static-10.provider.net") {
|
||||
t.Errorf("records = %v, want the PTR name that was actually found", srv.PTR.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPTRMissing(t *testing.T) {
|
||||
f := &fakeResolver{addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")}}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusError {
|
||||
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPartialPTRWarns(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10", "2001:db8::1")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusWarn {
|
||||
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHostnameDoesNotResolve(t *testing.T) {
|
||||
srv := newTestChecker(&fakeResolver{}).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusError {
|
||||
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 0 {
|
||||
t.Errorf("IPs = %v, want none", srv.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHostnameUnset(t *testing.T) {
|
||||
srv := newTestChecker(&fakeResolver{}).Server("", false)
|
||||
if srv.PTR.Status != health.StatusUnknown {
|
||||
t.Errorf("status = %q, want unknown when SELFPOST_HOSTNAME is unset", srv.PTR.Status)
|
||||
}
|
||||
}
|
||||
|
||||
const testDKIMValue = "v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqTESTKEY"
|
||||
|
||||
func dkimQuery(records map[string][]string) (*fakeResolver, Query) {
|
||||
q := Query{
|
||||
Name: "example.com",
|
||||
Selector: "selfpost",
|
||||
ExpectedDKIM: testDKIMValue,
|
||||
ServerIPs: []string{"203.0.113.10"},
|
||||
}
|
||||
return &fakeResolver{txt: records}, q
|
||||
}
|
||||
|
||||
func TestDKIMPublishedAndMatching(t *testing.T) {
|
||||
f, q := dkimQuery(map[string][]string{
|
||||
// Published with different spacing and a line break in the base64, as
|
||||
// DNS providers and TXT chunking produce.
|
||||
"selfpost._domainkey.example.com": {"v=DKIM1;h=sha256;k=rsa;p=MIIBIjANBgkq TESTKEY"},
|
||||
})
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusOK {
|
||||
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMMissing(t *testing.T) {
|
||||
f, q := dkimQuery(nil)
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusError {
|
||||
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMWrongKey(t *testing.T) {
|
||||
f, q := dkimQuery(map[string][]string{
|
||||
"selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p=SOMEOTHERKEY"},
|
||||
})
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusError {
|
||||
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
|
||||
}
|
||||
if !strings.Contains(got.DKIM.Detail, "not the one this server signs with") {
|
||||
t.Errorf("detail does not explain the mismatch: %s", got.DKIM.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMRevoked(t *testing.T) {
|
||||
f, q := dkimQuery(map[string][]string{
|
||||
"selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p="},
|
||||
})
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusError || !strings.Contains(got.DKIM.Detail, "revokes") {
|
||||
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMLookupFailureIsUnknown(t *testing.T) {
|
||||
f, q := dkimQuery(nil)
|
||||
f.fail = map[string]bool{"selfpost._domainkey.example.com": true}
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusUnknown {
|
||||
t.Errorf("status = %q (%s), want unknown when the resolver fails", got.DKIM.Status, got.DKIM.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPF(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
record []string
|
||||
want health.Status
|
||||
}{
|
||||
{"literal ip4", []string{"v=spf1 ip4:203.0.113.10 -all"}, health.StatusOK},
|
||||
{"covering CIDR", []string{"v=spf1 ip4:203.0.113.0/24 -all"}, health.StatusOK},
|
||||
{"other address only", []string{"v=spf1 ip4:198.51.100.7 -all"}, health.StatusError},
|
||||
{"include cannot be followed", []string{"v=spf1 include:_spf.provider.net -all"}, health.StatusWarn},
|
||||
{"plus all", []string{"v=spf1 +all"}, health.StatusWarn},
|
||||
{"negative qualifier does not authorise", []string{"v=spf1 -ip4:203.0.113.10 -all"}, health.StatusError},
|
||||
{"two records", []string{"v=spf1 ip4:203.0.113.10 -all", "v=spf1 -all"}, health.StatusError},
|
||||
{"no record", nil, health.StatusError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
txt := map[string][]string{}
|
||||
if c.record != nil {
|
||||
txt["example.com"] = c.record
|
||||
}
|
||||
f := &fakeResolver{txt: txt}
|
||||
got := newTestChecker(f).checkSPF(context.Background(), Query{
|
||||
Name: "example.com",
|
||||
ServerIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
if got.Status != c.want {
|
||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPFAMechanism(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
txt: map[string][]string{"example.com": {"v=spf1 a -all"}},
|
||||
addr: map[string][]net.IPAddr{"example.com": ipAddrs("203.0.113.10")},
|
||||
}
|
||||
got := newTestChecker(f).checkSPF(context.Background(), Query{
|
||||
Name: "example.com",
|
||||
ServerIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
if got.Status != health.StatusOK {
|
||||
t.Errorf("status = %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPFMXMechanism(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
txt: map[string][]string{"example.com": {"v=spf1 mx -all"}},
|
||||
mx: map[string][]*net.MX{"example.com": {{Host: "mail.example.com.", Pref: 10}}},
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
}
|
||||
got := newTestChecker(f).checkSPF(context.Background(), Query{
|
||||
Name: "example.com",
|
||||
ServerIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
if got.Status != health.StatusOK {
|
||||
t.Errorf("status = %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPFWithoutServerIPIsUnknown(t *testing.T) {
|
||||
f := &fakeResolver{txt: map[string][]string{"example.com": {"v=spf1 -all"}}}
|
||||
got := newTestChecker(f).checkSPF(context.Background(), Query{Name: "example.com"})
|
||||
if got.Status != health.StatusUnknown {
|
||||
t.Errorf("status = %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMARC(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
record []string
|
||||
want health.Status
|
||||
}{
|
||||
{"reject", []string{"v=DMARC1; p=reject; rua=mailto:dmarc@example.com"}, health.StatusOK},
|
||||
{"none", []string{"v=DMARC1; p=none"}, health.StatusOK},
|
||||
{"no policy tag", []string{"v=DMARC1; rua=mailto:dmarc@example.com"}, health.StatusWarn},
|
||||
{"absent", nil, health.StatusWarn},
|
||||
{"duplicated", []string{"v=DMARC1; p=none", "v=DMARC1; p=reject"}, health.StatusError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
txt := map[string][]string{}
|
||||
if c.record != nil {
|
||||
txt["_dmarc.example.com"] = c.record
|
||||
}
|
||||
f := &fakeResolver{txt: txt}
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
||||
if got.Status != c.want {
|
||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
||||
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
||||
if !strings.Contains(got.Detail, "monitoring only") {
|
||||
t.Errorf("p=none is not explained: %s", got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultsAreCachedAndForceBypassesTheCache(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
}
|
||||
c := newTestChecker(f)
|
||||
|
||||
c.Server("mail.example.com", false)
|
||||
after := f.lookups
|
||||
if after == 0 {
|
||||
t.Fatal("the first check did not query the resolver")
|
||||
}
|
||||
c.Server("mail.example.com", false)
|
||||
if f.lookups != after {
|
||||
t.Errorf("a second check re-queried DNS: %d lookups, want %d", f.lookups, after)
|
||||
}
|
||||
c.Server("mail.example.com", true)
|
||||
if f.lookups == after {
|
||||
t.Error("force did not bypass the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainOverallIsTheWorstOfTheThree(t *testing.T) {
|
||||
f, q := dkimQuery(map[string][]string{
|
||||
"selfpost._domainkey.example.com": {testDKIMValue},
|
||||
"example.com": {"v=spf1 ip4:203.0.113.10 -all"},
|
||||
// No DMARC: a warning.
|
||||
})
|
||||
got := newTestChecker(f).Domain(q, false)
|
||||
if got.DKIM.Status != health.StatusOK || got.SPF.Status != health.StatusOK {
|
||||
t.Fatalf("DKIM=%q SPF=%q", got.DKIM.Status, got.SPF.Status)
|
||||
}
|
||||
if got.Overall != health.StatusWarn {
|
||||
t.Errorf("overall = %q, want the DMARC warning to surface", got.Overall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgetDropsTheCachedDomain(t *testing.T) {
|
||||
f, q := dkimQuery(map[string][]string{"selfpost._domainkey.example.com": {testDKIMValue}})
|
||||
c := newTestChecker(f)
|
||||
c.Domain(q, false)
|
||||
before := f.lookups
|
||||
c.Forget(q.Name)
|
||||
c.Domain(q, false)
|
||||
if f.lookups == before {
|
||||
t.Error("Forget did not drop the cached result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SPF and DMARC are the two records SelfPost cannot generate for the operator:
|
||||
// unlike the DKIM record they are policy, not a key, and a domain may already
|
||||
// publish one for other senders. The panel still has to say what "correct"
|
||||
// looks like, and the checks below have to suggest the same thing when a record
|
||||
// is missing — so both take their example from here rather than each spelling
|
||||
// out its own.
|
||||
|
||||
// DMARCRecordName is the name a DMARC record is published at. (SPF has no such
|
||||
// helper: it is published at the domain itself.)
|
||||
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
||||
|
||||
// SPFExample is the SPF record this server expects for a sending domain: the
|
||||
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
||||
// is authorised. When the server's own addresses are not known (its hostname
|
||||
// does not resolve) it falls back to an "a:" mechanism naming the host, so the
|
||||
// panel always has something concrete to show.
|
||||
func SPFExample(hostname string, serverIPs []string) string {
|
||||
var mechanisms []string
|
||||
for _, s := range serverIPs {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
switch {
|
||||
case ip == nil:
|
||||
continue
|
||||
case ip.To4() != nil:
|
||||
mechanisms = append(mechanisms, "ip4:"+ip.String())
|
||||
default:
|
||||
mechanisms = append(mechanisms, "ip6:"+ip.String())
|
||||
}
|
||||
}
|
||||
if len(mechanisms) == 0 {
|
||||
mechanisms = []string{"a:" + hostname}
|
||||
}
|
||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||
}
|
||||
|
||||
// DMARCExample is the least a domain should publish: monitoring only, with an
|
||||
// address the aggregate reports go to. p=none is deliberate — it changes
|
||||
// nothing about delivery, so it is safe to publish before the reports have
|
||||
// shown that DKIM and SPF pass everywhere.
|
||||
func DMARCExample(domainName string) string {
|
||||
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
func TestSPFExample(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hostname string
|
||||
ips []string
|
||||
want string
|
||||
}{
|
||||
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
||||
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
||||
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
||||
// The hostname does not resolve, so there is no address to name; an "a:"
|
||||
// mechanism still gives the operator a publishable record.
|
||||
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
||||
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := SPFExample(c.hostname, c.ips); got != c.want {
|
||||
t.Errorf("SPFExample = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The record the panel shows and the one a failed check suggests must be the
|
||||
// same string, or the operator is told two different things on one page.
|
||||
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||
f := &fakeResolver{}
|
||||
c := newTestChecker(f)
|
||||
|
||||
spf := c.checkSPF(context.Background(), Query{
|
||||
Name: "example.com",
|
||||
Hostname: "mail.example.com",
|
||||
ServerIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
if spf.Status != health.StatusError {
|
||||
t.Fatalf("SPF status = %q, want error (%s)", spf.Status, spf.Detail)
|
||||
}
|
||||
if want := SPFExample("mail.example.com", []string{"203.0.113.10"}); !strings.Contains(spf.Detail, want) {
|
||||
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
||||
}
|
||||
|
||||
dmarc := c.checkDMARC(context.Background(), "example.com")
|
||||
if dmarc.Status != health.StatusWarn {
|
||||
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
||||
}
|
||||
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
|
||||
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// checkDKIM compares the TXT record published at <selector>._domainkey.<domain>
|
||||
// with the key this server signs with. A wrong or absent record means every
|
||||
// message fails DKIM at the receiver, so both are errors.
|
||||
func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
||||
name := q.Selector + "._domainkey." + q.Name
|
||||
txt, found, err := c.lookupTXT(ctx, name)
|
||||
if err != nil {
|
||||
return lookupFailed("the DKIM record", err)
|
||||
}
|
||||
|
||||
expected := publicKeyTag(q.ExpectedDKIM)
|
||||
if !found {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No TXT record is published at %s. Publish the record shown above — until then every message fails DKIM.", name),
|
||||
}
|
||||
}
|
||||
|
||||
for _, rec := range txt {
|
||||
got := publicKeyTag(rec)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
if got == expected {
|
||||
return Result{
|
||||
Status: health.StatusOK,
|
||||
Detail: fmt.Sprintf("Published at %s and matching the key this server signs with.", name),
|
||||
Records: txt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Something is published, but it is not our key. Separate the revoked case
|
||||
// (empty p=), which reads as a deliberate act rather than a typo.
|
||||
for _, rec := range txt {
|
||||
if v, ok := tagValue(rec, "p"); ok && v == "" {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("The record at %s has an empty p= tag, which revokes the key. Replace it with the record shown above.", name),
|
||||
Records: txt,
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("A TXT record exists at %s but its public key is not the one this server signs with — mail will fail DKIM. Replace it with the record shown above (an old record from a previous server is the usual cause).", name),
|
||||
Records: txt,
|
||||
}
|
||||
}
|
||||
|
||||
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
||||
// required for delivery, so its absence is advice (warn), not a fault.
|
||||
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
||||
name := DMARCRecordName(domainName)
|
||||
txt, found, err := c.lookupTXT(ctx, name)
|
||||
if err != nil {
|
||||
return lookupFailed("the DMARC record", err)
|
||||
}
|
||||
|
||||
var records []string
|
||||
for _, rec := range txt {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||
records = append(records, rec)
|
||||
}
|
||||
}
|
||||
if !found || len(records) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
|
||||
}
|
||||
}
|
||||
if len(records) > 1 {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("More than one DMARC record is published at %s. Receivers treat that as no policy at all — keep exactly one.", name),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
|
||||
policy, ok := tagValue(records[0], "p")
|
||||
if !ok || policy == "" {
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: "A DMARC record is published but has no p= policy tag, so receivers ignore it. Add p=none, p=quarantine or p=reject.",
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
detail := fmt.Sprintf("Published with policy p=%s.", policy)
|
||||
if strings.EqualFold(policy, "none") {
|
||||
detail += " That is monitoring only — tighten it to quarantine or reject once the reports look clean."
|
||||
}
|
||||
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
||||
}
|
||||
|
||||
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
||||
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
||||
// line breaks into the base64, none of which are part of the key.
|
||||
func publicKeyTag(record string) string {
|
||||
v, ok := tagValue(record, "p")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(strings.Fields(v), "")
|
||||
}
|
||||
|
||||
// tagValue reads one tag from a DKIM/DMARC-style "tag=value; tag=value" record.
|
||||
// Tag names are case-sensitive per RFC 6376/7489, and values keep their case.
|
||||
func tagValue(record, tag string) (string, bool) {
|
||||
for _, part := range strings.Split(record, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
key, value, found := strings.Cut(part, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(key) == tag {
|
||||
return strings.TrimSpace(value), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultResolvers are the recursive resolvers the checks query when the
|
||||
// deployment does not name its own (SELFPOST_DNS_RESOLVERS). Three independent
|
||||
// operators, so one being unreachable from the host does not blind the checks.
|
||||
var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
|
||||
// externalResolver talks to a fixed list of recursive resolvers directly
|
||||
// instead of going through the system resolver.
|
||||
//
|
||||
// That detour is deliberate: these checks answer "what does a receiving mail
|
||||
// server see about us?", and the machine's own stub resolver is the one place
|
||||
// where the answer differs. systemd-resolved — which a Docker container reaches
|
||||
// through the embedded 127.0.0.11 forwarder — synthesises a PTR record for the
|
||||
// host's own addresses out of the local hostname, never asking public DNS. A
|
||||
// server whose reverse DNS was published correctly therefore had its
|
||||
// provider-assigned hostname reported back to the panel, and the FCrDNS check
|
||||
// failed a record that was in fact right.
|
||||
//
|
||||
// This bypasses the resolvers in /etc/resolv.conf, not /etc/hosts: Go still
|
||||
// consults the hosts file first, as any local program would.
|
||||
type externalResolver struct {
|
||||
servers []*net.Resolver
|
||||
}
|
||||
|
||||
// newExternalResolver builds a resolver over addrs ("host" or "host:port"). An
|
||||
// empty list falls back to DefaultResolvers.
|
||||
func newExternalResolver(addrs []string) *externalResolver {
|
||||
if len(addrs) == 0 {
|
||||
addrs = DefaultResolvers
|
||||
}
|
||||
e := &externalResolver{}
|
||||
for _, a := range addrs {
|
||||
addr := withDefaultPort(a)
|
||||
e.servers = append(e.servers, &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
},
|
||||
})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupTXT(ctx, name) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]net.IPAddr, error) { return r.LookupIPAddr(ctx, host) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupAddr(ctx, addr) })
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]*net.MX, error) { return r.LookupMX(ctx, name) })
|
||||
}
|
||||
|
||||
// queryEach asks each resolver in turn and stops at the first one that
|
||||
// answers. "No such name" is an answer — only a resolver that cannot be
|
||||
// reached, or that times out, moves the query on to the next one.
|
||||
func queryEach[T any](servers []*net.Resolver, ask func(*net.Resolver) (T, error)) (T, error) {
|
||||
var zero T
|
||||
var lastErr error
|
||||
for _, r := range servers {
|
||||
v, err := ask(r)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return zero, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no DNS resolver configured")
|
||||
}
|
||||
return zero, lastErr
|
||||
}
|
||||
|
||||
// withDefaultPort appends the DNS port to a bare address, so the environment
|
||||
// variable can name a resolver as plainly as "1.1.1.1".
|
||||
func withDefaultPort(addr string) string {
|
||||
if _, _, err := net.SplitHostPort(addr); err == nil {
|
||||
return addr
|
||||
}
|
||||
return net.JoinHostPort(addr, "53")
|
||||
}
|
||||
|
||||
// ParseResolvers reads a comma-separated resolver list, as it arrives from the
|
||||
// environment. Blank entries are skipped; an empty result means "use
|
||||
// DefaultResolvers".
|
||||
func ParseResolvers(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithDefaultPort(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.1.1.1": "1.1.1.1:53",
|
||||
"1.1.1.1:5353": "1.1.1.1:5353",
|
||||
"dns.example.com": "dns.example.com:53",
|
||||
"2606:4700:4700::": "[2606:4700:4700::]:53",
|
||||
"[::1]:5353": "[::1]:5353",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := withDefaultPort(in); got != want {
|
||||
t.Errorf("withDefaultPort(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolvers(t *testing.T) {
|
||||
got := ParseResolvers(" 1.1.1.1 , ,8.8.8.8:53,")
|
||||
want := []string{"1.1.1.1", "8.8.8.8:53"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if r := ParseResolvers(""); len(r) != 0 {
|
||||
t.Errorf("empty string parsed to %q, want nothing", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExternalResolverFallsBackToDefaults(t *testing.T) {
|
||||
if n := len(newExternalResolver(nil).servers); n != len(DefaultResolvers) {
|
||||
t.Errorf("resolver count = %d, want %d (the defaults)", n, len(DefaultResolvers))
|
||||
}
|
||||
}
|
||||
|
||||
// The checks must reach the second resolver when the first is unreachable, but
|
||||
// must not second-guess an authoritative "no such name" — otherwise a domain
|
||||
// that genuinely lacks a record costs one timeout per configured resolver.
|
||||
func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
servers := []*net.Resolver{{}, {}, {}}
|
||||
|
||||
asked := 0
|
||||
got, err := queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
asked++
|
||||
if asked < 3 {
|
||||
return "", &net.DNSError{Err: "timed out", IsTimeout: true}
|
||||
}
|
||||
return "answer", nil
|
||||
})
|
||||
if err != nil || got != "answer" {
|
||||
t.Fatalf("got (%q, %v), want (\"answer\", nil)", got, err)
|
||||
}
|
||||
if asked != 3 {
|
||||
t.Errorf("asked %d resolvers, want 3", asked)
|
||||
}
|
||||
|
||||
asked = 0
|
||||
_, err = queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
asked++
|
||||
return "", notFound("absent.example")
|
||||
})
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound {
|
||||
t.Fatalf("err = %v, want a not-found DNSError", err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Errorf("NXDOMAIN asked %d resolvers, want 1 — it is an answer, not a failure", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this package does not use net.DefaultResolver: systemd-resolved
|
||||
// answers PTR queries for the machine's own addresses out of the local
|
||||
// hostname, which is not what the rest of the internet sees. The dial hook must
|
||||
// therefore ignore the address the standard resolver picked from
|
||||
// /etc/resolv.conf and connect to the configured one.
|
||||
func TestExternalResolverDialsOnlyTheConfiguredAddress(t *testing.T) {
|
||||
e := newExternalResolver([]string{"192.0.2.53"})
|
||||
if len(e.servers) != 1 {
|
||||
t.Fatalf("servers = %d, want 1", len(e.servers))
|
||||
}
|
||||
conn, err := e.servers[0].Dial(context.Background(), "udp", "127.0.0.53:53")
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if got := conn.RemoteAddr().String(); got != "192.0.2.53:53" {
|
||||
t.Errorf("connected to %q, want 192.0.2.53:53 (the system resolver won)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// checkServer resolves the panel's own hostname and confirms the reverse
|
||||
// lookup of each address points back at that name (FCrDNS). A missing or
|
||||
// mismatched PTR is the single most common reason mail from a self-hosted
|
||||
// server is rejected or scored as spam, which is why it is an error and not
|
||||
// advice.
|
||||
func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
srv := Server{Hostname: hostname, CheckedAt: time.Now()}
|
||||
if hostname == "" {
|
||||
srv.PTR = Result{
|
||||
Status: health.StatusUnknown,
|
||||
Detail: "SELFPOST_HOSTNAME is not set, so the server's own name in DNS cannot be checked. Set it in the deployment environment.",
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
addrs, err := c.resolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
srv.PTR = Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("%s does not resolve to any address. Publish an A (or AAAA) record for it — receiving servers check the name this server announces in HELO.", hostname),
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
want := normalizeName(hostname)
|
||||
matched, total := 0, len(addrs)
|
||||
var records []string
|
||||
for _, a := range addrs {
|
||||
ip := a.IP.String()
|
||||
srv.IPs = append(srv.IPs, ip)
|
||||
|
||||
names, err := c.resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
records = append(records, ip+" → no PTR record")
|
||||
continue
|
||||
}
|
||||
hit := false
|
||||
for _, n := range names {
|
||||
if normalizeName(n) == want {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
matched++
|
||||
records = append(records, ip+" → "+normalizeName(names[0]))
|
||||
} else {
|
||||
records = append(records, ip+" → "+normalizeName(names[0])+" (does not match)")
|
||||
}
|
||||
}
|
||||
|
||||
srv.PTR.Records = records
|
||||
switch {
|
||||
case matched == total:
|
||||
srv.PTR.Status = health.StatusOK
|
||||
srv.PTR.Detail = fmt.Sprintf("%s resolves to %s and the reverse lookup points back at it.", hostname, joinIPs(srv.IPs))
|
||||
case matched > 0:
|
||||
srv.PTR.Status = health.StatusWarn
|
||||
srv.PTR.Detail = fmt.Sprintf("Only %d of %d addresses of %s have a matching PTR record. Mail sent from the others may be rejected — set the reverse DNS of every address at your hosting provider.", matched, total, hostname)
|
||||
default:
|
||||
srv.PTR.Status = health.StatusError
|
||||
srv.PTR.Detail = fmt.Sprintf("No address of %s has a reverse (PTR) record pointing back at it. Many receiving servers reject or spam-score mail from such a host — set the reverse DNS of the server's IP to %s at your hosting provider.", hostname, hostname)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func joinIPs(ips []string) string {
|
||||
switch len(ips) {
|
||||
case 0:
|
||||
return "no address"
|
||||
case 1:
|
||||
return ips[0]
|
||||
default:
|
||||
out := ips[0]
|
||||
for _, ip := range ips[1:] {
|
||||
out += ", " + ip
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
|
||||
// a/mx mechanisms. RFC 7208 allows a receiver ten; staying under the same
|
||||
// ceiling keeps a hostile or careless record from turning a page view into a
|
||||
// long chain of queries.
|
||||
const spfLookupBudget = 10
|
||||
|
||||
// checkSPF reports whether the domain's SPF record authorises this server.
|
||||
//
|
||||
// This is deliberately a shallow check (documented as such in the README): it
|
||||
// looks for a mechanism that literally covers the server's address —
|
||||
// ip4:/ip6:, or a/mx resolving to it — and does not recurse into include: or
|
||||
// redirect=, nor evaluate the record the way a receiver would. That is why a
|
||||
// record which does not obviously cover us but does use include: is reported as
|
||||
// a warning ("cannot tell") rather than a failure.
|
||||
func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
|
||||
ips := parseIPs(q.ServerIPs)
|
||||
if len(ips) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusUnknown,
|
||||
Detail: "The server's own address is not known (its hostname does not resolve), so SPF coverage cannot be checked. Fix the hostname/PTR check first.",
|
||||
}
|
||||
}
|
||||
|
||||
txt, found, err := c.lookupTXT(ctx, q.Name)
|
||||
if err != nil {
|
||||
return lookupFailed("the SPF record", err)
|
||||
}
|
||||
|
||||
var records []string
|
||||
for _, rec := range txt {
|
||||
if isSPF(rec) {
|
||||
records = append(records, strings.TrimSpace(rec))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !found || len(records) == 0:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as %q — without it receivers have nothing authorising this server to send as the domain.", q.Name, SPFExample(q.Hostname, q.ServerIPs)),
|
||||
}
|
||||
case len(records) > 1:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("More than one SPF record is published for %s. RFC 7208 allows exactly one; receivers treat several as a permanent error and the domain gets no SPF pass at all. Merge them into a single record.", q.Name),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
|
||||
matched, unfollowed := c.evaluateSPF(ctx, records[0], q.Name, ips)
|
||||
switch {
|
||||
case matched == "+all" || matched == "all":
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: "The SPF record ends with \"+all\", which authorises every server on the internet to send as this domain. Replace it with an explicit ip4:/ip6: or a mechanism plus \"-all\".",
|
||||
Records: records,
|
||||
}
|
||||
case matched != "":
|
||||
return Result{
|
||||
Status: health.StatusOK,
|
||||
Detail: fmt.Sprintf("The SPF record authorises this server through its %q mechanism.", matched),
|
||||
Records: records,
|
||||
}
|
||||
case len(unfollowed) > 0:
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: fmt.Sprintf("No mechanism in the SPF record lists %s directly, but the record uses %s, which this check does not follow — the server may still be authorised through it. Verify with an external SPF validator, or add \"ip4:%s\" to be sure.",
|
||||
ips[0], strings.Join(unfollowed, ", "), ips[0]),
|
||||
Records: records,
|
||||
}
|
||||
default:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("The SPF record does not authorise %s, so mail sent from this server fails SPF. Add \"ip4:%s\" (or an \"a\" mechanism resolving here) to the record.", ips[0], ips[0]),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateSPF walks the record's mechanisms, returning the first one that
|
||||
// covers one of the server's addresses, plus the mechanisms this shallow check
|
||||
// cannot resolve (include:/redirect=/exists:/ptr and anything past the lookup
|
||||
// budget) so the caller can say "cannot tell" instead of "fails".
|
||||
func (c *Checker) evaluateSPF(ctx context.Context, record, domainName string, ips []net.IP) (matched string, unfollowed []string) {
|
||||
budget := spfLookupBudget
|
||||
seenUnfollowed := make(map[string]bool)
|
||||
note := func(kind string) {
|
||||
if !seenUnfollowed[kind] {
|
||||
seenUnfollowed[kind] = true
|
||||
unfollowed = append(unfollowed, kind)
|
||||
}
|
||||
}
|
||||
|
||||
terms := strings.Fields(record)
|
||||
if len(terms) > 0 {
|
||||
terms = terms[1:] // drop the v=spf1 version token
|
||||
}
|
||||
for _, term := range terms {
|
||||
qualifier, mech := splitQualifier(term)
|
||||
lower := strings.ToLower(mech)
|
||||
name, hasArg := mechanismArg(mech)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
|
||||
if qualifier != '+' {
|
||||
continue
|
||||
}
|
||||
if coversAny(mech[4:], ips) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
|
||||
if strings.Contains(mech, "/") { // prefix-length form: not evaluated
|
||||
note("a/<prefix>")
|
||||
continue
|
||||
}
|
||||
target := domainName
|
||||
if hasArg {
|
||||
target = name
|
||||
}
|
||||
if budget <= 0 {
|
||||
note("further lookups")
|
||||
continue
|
||||
}
|
||||
budget--
|
||||
if qualifier == '+' && c.resolvesTo(ctx, target, ips) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"):
|
||||
if strings.Contains(mech, "/") {
|
||||
note("mx/<prefix>")
|
||||
continue
|
||||
}
|
||||
target := domainName
|
||||
if hasArg {
|
||||
target = name
|
||||
}
|
||||
if budget <= 0 {
|
||||
note("further lookups")
|
||||
continue
|
||||
}
|
||||
budget--
|
||||
if qualifier == '+' && c.mxResolvesTo(ctx, target, ips, &budget) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case strings.HasPrefix(lower, "include:"):
|
||||
note("include:")
|
||||
case strings.HasPrefix(lower, "redirect="):
|
||||
note("redirect=")
|
||||
case strings.HasPrefix(lower, "exists:"):
|
||||
note("exists:")
|
||||
case lower == "ptr" || strings.HasPrefix(lower, "ptr:"):
|
||||
note("ptr")
|
||||
|
||||
case lower == "all":
|
||||
if qualifier == '+' {
|
||||
return "+all", unfollowed
|
||||
}
|
||||
// "-all"/"~all"/"?all" terminates the record: nothing after it is
|
||||
// evaluated by a receiver either.
|
||||
return "", unfollowed
|
||||
}
|
||||
}
|
||||
return "", unfollowed
|
||||
}
|
||||
|
||||
// resolvesTo reports whether name resolves to one of the server's addresses.
|
||||
func (c *Checker) resolvesTo(ctx context.Context, name string, ips []net.IP) bool {
|
||||
addrs, err := c.resolver.LookupIPAddr(ctx, name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
for _, ip := range ips {
|
||||
if a.IP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mxResolvesTo reports whether any of name's MX hosts resolves to one of the
|
||||
// server's addresses, spending at most the remaining lookup budget.
|
||||
func (c *Checker) mxResolvesTo(ctx context.Context, name string, ips []net.IP, budget *int) bool {
|
||||
mxs, err := c.resolver.LookupMX(ctx, name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, mx := range mxs {
|
||||
if *budget <= 0 {
|
||||
return false
|
||||
}
|
||||
*budget--
|
||||
if c.resolvesTo(ctx, strings.TrimSuffix(mx.Host, "."), ips) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coversAny reports whether an ip4:/ip6: value — a bare address or a CIDR —
|
||||
// contains one of the server's addresses.
|
||||
func coversAny(value string, ips []net.IP) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "/") {
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
listed := net.ParseIP(value)
|
||||
if listed == nil {
|
||||
return false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if listed.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitQualifier peels the optional +/-/~/? qualifier off a mechanism,
|
||||
// defaulting to "+" (pass) as RFC 7208 does.
|
||||
func splitQualifier(term string) (byte, string) {
|
||||
if term == "" {
|
||||
return '+', ""
|
||||
}
|
||||
switch term[0] {
|
||||
case '+', '-', '~', '?':
|
||||
return term[0], term[1:]
|
||||
default:
|
||||
return '+', term
|
||||
}
|
||||
}
|
||||
|
||||
// mechanismArg returns the ":" argument of a mechanism, if it has one.
|
||||
func mechanismArg(mech string) (string, bool) {
|
||||
_, arg, found := strings.Cut(mech, ":")
|
||||
if !found || arg == "" {
|
||||
return "", false
|
||||
}
|
||||
return arg, true
|
||||
}
|
||||
|
||||
// isSPF reports whether a TXT record is an SPF record (the version token must
|
||||
// be the whole first term, so "v=spf10" is not one).
|
||||
func isSPF(record string) bool {
|
||||
rec := strings.TrimSpace(record)
|
||||
if len(rec) < 6 || !strings.EqualFold(rec[:6], "v=spf1") {
|
||||
return false
|
||||
}
|
||||
return len(rec) == 6 || rec[6] == ' ' || rec[6] == '\t'
|
||||
}
|
||||
|
||||
// parseIPs converts the string addresses carried on a Query back into net.IPs,
|
||||
// dropping anything unparsable.
|
||||
func parseIPs(in []string) []net.IP {
|
||||
var ips []net.IP
|
||||
for _, s := range in {
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
|
||||
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
|
||||
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
|
||||
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so
|
||||
// it survives container restarts (architecture.md § OpenDKIM).
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
|
||||
// interoperability sweet spot: strong, and short enough that the published
|
||||
// public key still fits comfortably in a DNS TXT record.
|
||||
const dkimKeyBits = 2048
|
||||
|
||||
// generateDKIMKey creates a fresh RSA private key for signing a domain.
|
||||
func generateDKIMKey() (*rsa.PrivateKey, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, dkimKeyBits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate dkim key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
|
||||
// atomically and group-readable (0640). The file is owned by the panel user and
|
||||
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf
|
||||
// and entrypoint.sh); the parent directory carries setgid so the group is
|
||||
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
||||
// a half-written key.
|
||||
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
||||
block := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640)
|
||||
}
|
||||
|
||||
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
|
||||
// writePrivateKeyPEM. It is used to recompute the public DNS record on demand,
|
||||
// keeping the private key file the single source of truth (product.md).
|
||||
func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("dkim key %s: not a PKCS#1 RSA private key", path)
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dkim key %s: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DKIMRecord is the DNS TXT record a user must publish for a domain (product.md).
|
||||
type DKIMRecord struct {
|
||||
// Name is the record's host, e.g. "selfpost._domainkey.example.com".
|
||||
Name string
|
||||
// Value is the TXT payload, e.g. "v=DKIM1; h=sha256; k=rsa; p=MIIB...".
|
||||
Value string
|
||||
}
|
||||
|
||||
// dkimRecord builds the published DKIM DNS record for a public key. The value
|
||||
// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key
|
||||
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
|
||||
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return DKIMRecord{}, fmt.Errorf("marshal dkim public key: %w", err)
|
||||
}
|
||||
p := base64.StdEncoding.EncodeToString(der)
|
||||
return DKIMRecord{
|
||||
Name: fmt.Sprintf("%s._domainkey.%s", selector, domainName),
|
||||
Value: fmt.Sprintf("v=DKIM1; h=sha256; k=rsa; p=%s", p),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file in the same directory
|
||||
// followed by a rename, so readers only ever see the complete old or new file.
|
||||
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
|
||||
// (security.md).
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generateDKIMKey: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "selfpost.private")
|
||||
if err := writePrivateKeyPEM(path, key); err != nil {
|
||||
t.Fatalf("writePrivateKeyPEM: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o640 {
|
||||
t.Errorf("key perm = %o, want 0640", perm)
|
||||
}
|
||||
|
||||
loaded, err := loadPrivateKeyPEM(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPrivateKeyPEM: %v", err)
|
||||
}
|
||||
if loaded.N.Cmp(key.N) != 0 || loaded.E != key.E {
|
||||
t.Error("loaded key does not match generated key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivateKeyRejectsGarbage(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.private")
|
||||
if err := os.WriteFile(path, []byte("not a pem"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadPrivateKeyPEM(path); err == nil {
|
||||
t.Error("expected error for non-PEM key file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDKIMRecord(t *testing.T) {
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := dkimRecord("selfpost", "example.com", &key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("dkimRecord: %v", err)
|
||||
}
|
||||
if rec.Name != "selfpost._domainkey.example.com" {
|
||||
t.Errorf("record name = %q", rec.Name)
|
||||
}
|
||||
for _, want := range []string{"v=DKIM1", "h=sha256", "k=rsa", "p="} {
|
||||
if !strings.Contains(rec.Value, want) {
|
||||
t.Errorf("record value %q missing %q", rec.Value, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "f")
|
||||
if err := writeFileAtomic(path, []byte("one"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte("two"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "two" {
|
||||
t.Errorf("content = %q, want %q", got, "two")
|
||||
}
|
||||
// No stray temp files left behind in the directory.
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected 1 file after atomic writes, found %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
|
||||
// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the
|
||||
// KeyTable/SigningTable that map domains to those keys. After rewriting the
|
||||
// tables it asks OpenDKIM to reload them.
|
||||
type OpenDKIM struct {
|
||||
keysDir string
|
||||
keyTablePath string
|
||||
signingTablePath string
|
||||
|
||||
// reload sends the running OpenDKIM a reload signal. It is a field so tests
|
||||
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
|
||||
reload func() error
|
||||
}
|
||||
|
||||
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
|
||||
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
|
||||
// through supervisord.
|
||||
func NewOpenDKIM(dir string) *OpenDKIM {
|
||||
return &OpenDKIM{
|
||||
keysDir: filepath.Join(dir, "keys"),
|
||||
keyTablePath: filepath.Join(dir, "KeyTable"),
|
||||
signingTablePath: filepath.Join(dir, "SigningTable"),
|
||||
reload: reloadViaSupervisor,
|
||||
}
|
||||
}
|
||||
|
||||
// SigningDomain is one row's worth of signing configuration.
|
||||
type SigningDomain struct {
|
||||
Name string
|
||||
Selector string
|
||||
}
|
||||
|
||||
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
|
||||
func (o *OpenDKIM) keyPath(domainName, selector string) string {
|
||||
return filepath.Join(o.keysDir, domainName, selector+".private")
|
||||
}
|
||||
|
||||
// EnsureKey makes sure a signing key exists for the domain. An existing key is
|
||||
// reused untouched — critical because overwriting it would silently invalidate
|
||||
// the DKIM record already published in DNS (architecture.md § OpenDKIM).
|
||||
// Returns whether a new key was generated.
|
||||
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil // reuse existing key
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("stat dkim key: %w", err)
|
||||
}
|
||||
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
|
||||
// shared `selfpost` group so OpenDKIM can traverse into it.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return false, fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := writePrivateKeyPEM(path, key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A
|
||||
// missing directory is not an error.
|
||||
func (o *OpenDKIM) RemoveKey(domainName string) error {
|
||||
if err := assertConfigSafe(domainName, "x"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
|
||||
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
|
||||
// a domain export so the receiving instance signs with the same key and the
|
||||
// DNS TXT record never has to change (architecture.md § Persistence). It
|
||||
// re-marshals the parsed key rather than returning the raw file, so a
|
||||
// malformed on-disk key is caught here.
|
||||
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||
return pem.EncodeToMemory(block), nil
|
||||
}
|
||||
|
||||
// ImportKey writes an imported DKIM private key to disk for a domain
|
||||
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
|
||||
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
|
||||
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
|
||||
// the domain with exactly this key, which is the whole point of keeping the
|
||||
// published DNS record valid.
|
||||
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return err
|
||||
}
|
||||
block, _ := pem.Decode(pemKey)
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
return writePrivateKeyPEM(path, key)
|
||||
}
|
||||
|
||||
// Record returns the published DKIM DNS record for a domain, recomputed from the
|
||||
// private key on disk (product.md).
|
||||
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return DKIMRecord{}, err
|
||||
}
|
||||
return dkimRecord(selector, domainName, &key.PublicKey)
|
||||
}
|
||||
|
||||
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
|
||||
// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather
|
||||
// than incremental edits) keeps the files a pure function of the registry, so
|
||||
// add and delete share one idempotent path. Both files are written atomically
|
||||
// before the reload signal is sent.
|
||||
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
|
||||
keyTable, signingTable, err := renderTables(o.keysDir, domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return o.reload()
|
||||
}
|
||||
|
||||
// Reload asks OpenDKIM to re-read its tables without regenerating them. It
|
||||
// backs the panel's manual reload button (architecture.md § Panel HTTP
|
||||
// surface).
|
||||
func (o *OpenDKIM) Reload() error {
|
||||
return o.reload()
|
||||
}
|
||||
|
||||
// renderTables builds the KeyTable and SigningTable byte contents for a domain
|
||||
// set, sorted by name so the output is deterministic. Every domain is
|
||||
// re-checked for shell/config-injection safety before being written
|
||||
// (security.md) — validation upstream already guarantees this, but the table
|
||||
// writer refuses to emit anything unsafe as a hard backstop.
|
||||
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
|
||||
sorted := append([]SigningDomain(nil), domains...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
|
||||
var kt, st strings.Builder
|
||||
for _, d := range sorted {
|
||||
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyName := d.Name // one key per domain; the domain name is a fine handle
|
||||
// Absolute key path so OpenDKIM resolves it independently of its CWD.
|
||||
keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private")
|
||||
// KeyTable: <key-name> <domain>:<selector>:<key-path>
|
||||
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
|
||||
// SigningTable (refile): <address-pattern> <key-name>
|
||||
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
|
||||
}
|
||||
return []byte(kt.String()), []byte(st.String()), nil
|
||||
}
|
||||
|
||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
||||
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
|
||||
// to a similar set before they reach here (security.md); this is defence in depth
|
||||
// against a validation gap ever letting whitespace, a newline or a field
|
||||
// separator through into a config file (security.md).
|
||||
func assertConfigSafe(domainName, selector string) error {
|
||||
for _, v := range []string{domainName, selector} {
|
||||
if v == "" {
|
||||
return fmt.Errorf("opendkim: empty domain or selector")
|
||||
}
|
||||
if strings.ContainsAny(v, " \t\r\n:/\\") {
|
||||
return fmt.Errorf("opendkim: unsafe character in %q", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
|
||||
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
|
||||
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
|
||||
// signal another user's process directly, so it goes through the supervisor
|
||||
// control socket, reachable via the shared `selfpost` group (security.md).
|
||||
//
|
||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||
// and it never goes through a shell (security.md).
|
||||
func reloadViaSupervisor() error {
|
||||
cmd := exec.Command("supervisorctl",
|
||||
"-c", "/etc/supervisor/supervisord.conf",
|
||||
"signal", "USR1", "opendkim")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTables(t *testing.T) {
|
||||
keysDir := "/data/opendkim/keys"
|
||||
// Deliberately out of order to exercise the deterministic sort.
|
||||
domains := []SigningDomain{
|
||||
{Name: "zeta.example", Selector: "selfpost"},
|
||||
{Name: "alpha.example", Selector: "sel2"},
|
||||
}
|
||||
kt, st, err := renderTables(keysDir, domains)
|
||||
if err != nil {
|
||||
t.Fatalf("renderTables: %v", err)
|
||||
}
|
||||
|
||||
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" +
|
||||
"zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n"
|
||||
if string(kt) != wantKT {
|
||||
t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT)
|
||||
}
|
||||
|
||||
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n"
|
||||
if string(st) != wantST {
|
||||
t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTablesEmpty(t *testing.T) {
|
||||
kt, st, err := renderTables("/keys", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("renderTables(nil): %v", err)
|
||||
}
|
||||
if len(kt) != 0 || len(st) != 0 {
|
||||
t.Errorf("expected empty tables, got kt=%q st=%q", kt, st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertConfigSafeRejectsInjection(t *testing.T) {
|
||||
bad := []struct{ name, sel string }{
|
||||
{"exa mple.com", "selfpost"},
|
||||
{"example.com\nInject yes", "selfpost"},
|
||||
{"example.com", "sel:evil"},
|
||||
{"../etc", "selfpost"},
|
||||
{"", "selfpost"},
|
||||
{"example.com", ""},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if err := assertConfigSafe(b.name, b.sel); err == nil {
|
||||
t.Errorf("assertConfigSafe(%q,%q) = nil, want error", b.name, b.sel)
|
||||
}
|
||||
}
|
||||
if err := assertConfigSafe("example.com", "selfpost"); err != nil {
|
||||
t.Errorf("assertConfigSafe of a clean pair errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out.
|
||||
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloads := 0
|
||||
o := NewOpenDKIM(dir)
|
||||
o.reload = func() error { reloads++; return nil }
|
||||
return o, &reloads
|
||||
}
|
||||
|
||||
func TestEnsureKeyReusesExisting(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
|
||||
created, err := o.EnsureKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected a new key to be created")
|
||||
}
|
||||
first, err := os.ReadFile(o.keyPath("example.com", "selfpost"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err = o.EnsureKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey (second): %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("expected existing key to be reused, not regenerated")
|
||||
}
|
||||
second, _ := os.ReadFile(o.keyPath("example.com", "selfpost"))
|
||||
if string(first) != string(second) {
|
||||
t.Error("key file changed on reuse — published DNS record would break")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildWritesTablesAndReloads(t *testing.T) {
|
||||
o, reloads := newTestOpenDKIM(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
|
||||
t.Fatalf("Rebuild: %v", err)
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("reload called %d times, want 1", *reloads)
|
||||
}
|
||||
kt, err := os.ReadFile(o.keyTablePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(kt), "example.com:selfpost:") {
|
||||
t.Errorf("KeyTable missing entry: %q", kt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := o.RemoveKey("example.com"); err != nil {
|
||||
t.Fatalf("RemoveKey: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(o.keysDir, "example.com")); !os.IsNotExist(err) {
|
||||
t.Error("key directory still present after RemoveKey")
|
||||
}
|
||||
// Removing a non-existent key is not an error.
|
||||
if err := o.RemoveKey("example.com"); err != nil {
|
||||
t.Errorf("RemoveKey on missing dir errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFromWrittenKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := o.Record("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
if rec.Name != "selfpost._domainkey.example.com" {
|
||||
t.Errorf("record name = %q", rec.Name)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Value, "v=DKIM1;") {
|
||||
t.Errorf("record value = %q", rec.Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Applications is the slice of the application service the domain service needs
|
||||
// to keep the SASL database and sender map consistent when a domain (and its
|
||||
// applications, via cascade) is deleted. *app.Service satisfies it; it is an
|
||||
// interface here to avoid a package import cycle and to keep domain deletion
|
||||
// testable in isolation.
|
||||
type Applications interface {
|
||||
// PurgeDomainSASL removes the SASL accounts of the domain's applications.
|
||||
// It must run before the registry cascade so the logins are still known.
|
||||
PurgeDomainSASL(domainID int64) error
|
||||
// Resync rebuilds smtpd_sender_login_maps from the remaining applications
|
||||
// and reloads Postfix.
|
||||
Resync() error
|
||||
// Secret returns an application's stored password, for a domain export
|
||||
// (architecture.md § Persistence).
|
||||
Secret(login string) (string, error)
|
||||
// ImportApplication re-creates an application (registry row + SASL account)
|
||||
// from a domain-export file, without rebuilding the sender map
|
||||
// (architecture.md § Persistence).
|
||||
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
|
||||
}
|
||||
|
||||
// Service coordinates the places a sending domain lives: the SQLite registry,
|
||||
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
|
||||
// database and Postfix sender map its applications touch. Callers (the web
|
||||
// handlers) validate user input first; Service keeps the stores in agreement
|
||||
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
|
||||
// product.md).
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
odk *OpenDKIM
|
||||
apps Applications
|
||||
selector string
|
||||
}
|
||||
|
||||
// NewService builds the domain service. selectorDefault is the DKIM selector
|
||||
// assigned to new domains (guide § Environment variables:
|
||||
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
|
||||
// used only on deletion, to clear the SASL accounts and sender-map bindings of
|
||||
// the domain's applications.
|
||||
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
|
||||
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
|
||||
}
|
||||
|
||||
// List returns all domains with application counts (product.md).
|
||||
func (s *Service) List() ([]store.Domain, error) {
|
||||
return s.store.ListDomains()
|
||||
}
|
||||
|
||||
// Get returns one domain by id (store.ErrDomainNotFound if absent).
|
||||
func (s *Service) Get(id int64) (store.Domain, error) {
|
||||
return s.store.GetDomain(id)
|
||||
}
|
||||
|
||||
// Add registers a new sending domain: it records the row, ensures a DKIM key
|
||||
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md).
|
||||
// name must already be normalised and validated by the caller. A duplicate
|
||||
// returns store.ErrDomainExists.
|
||||
//
|
||||
// The registry row is written first so its UNIQUE constraint is the single
|
||||
// arbiter of "already exists" (avoiding a check-then-act race). An existing
|
||||
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB
|
||||
// row was lost keeps its published DNS record valid. If key generation or the
|
||||
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
|
||||
// domain that OpenDKIM cannot sign.
|
||||
func (s *Service) Add(name string) (store.Domain, error) {
|
||||
d, err := s.store.AddDomain(name, s.selector)
|
||||
if err != nil {
|
||||
return store.Domain{}, err
|
||||
}
|
||||
|
||||
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil {
|
||||
s.rollbackAdd(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
if err := s.resync(); err != nil {
|
||||
s.rollbackAdd(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// rollbackAdd best-effort removes a half-created domain after a downstream
|
||||
// failure. Errors here are logged by the caller's returned error path; the key
|
||||
// (if freshly generated) is left in place harmlessly and reused on retry.
|
||||
func (s *Service) rollbackAdd(id int64) {
|
||||
_ = s.store.DeleteDomain(id)
|
||||
}
|
||||
|
||||
// Delete removes a domain and everything bound to it (product.md). The
|
||||
// order matters: the applications' SASL accounts are cleared first, while their
|
||||
// logins are still in the registry; then the registry rows (applications and
|
||||
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
|
||||
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
|
||||
// and Postfix stops authorising the domain's senders — before the DKIM key is
|
||||
// deleted.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
d, err := s.store.GetDomain(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.apps.PurgeDomainSASL(id); err != nil {
|
||||
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
|
||||
}
|
||||
// Drop the domain's own level-2 limit and those of its applications while the
|
||||
// application rows still exist (the cleanup query joins them). rate_limits
|
||||
// has no cascade of its own (ref_id is a plain integer, guide § Rate
|
||||
// limiting; architecture.md § Persistence).
|
||||
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
|
||||
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
|
||||
}
|
||||
if err := s.store.DeleteDomain(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.resync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.apps.Resync(); err != nil {
|
||||
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err)
|
||||
}
|
||||
if err := s.odk.RemoveKey(d.Name); err != nil {
|
||||
// The domain is gone from the registry and tables; a leftover key
|
||||
// directory is harmless. Surface it so it is not silently ignored.
|
||||
return fmt.Errorf("domain deleted but key cleanup failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
|
||||
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
||||
return s.odk.Record(d.Name, d.DKIMSelector)
|
||||
}
|
||||
|
||||
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
|
||||
// limiting), and whether one is configured, for the domain's edit form.
|
||||
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
||||
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
}
|
||||
|
||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||
// IPs and numbers (security.md); the milter reads the row live, so no reload is
|
||||
// needed.
|
||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||
return s.store.SetRateLimit(store.RateLimit{
|
||||
Scope: store.RateLimitScopeDomain,
|
||||
RefID: domainID,
|
||||
AllowedIPs: ips,
|
||||
MaxMessages: maxMessages,
|
||||
WindowSeconds: windowSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
||||
// only (guide § Rate limiting).
|
||||
func (s *Service) ClearRateLimit(domainID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
}
|
||||
|
||||
// Resync regenerates the OpenDKIM tables from the registry and reloads
|
||||
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
|
||||
// surface) and doubles as a recovery path if the tables ever drift from the
|
||||
// database.
|
||||
func (s *Service) Resync() error {
|
||||
return s.resync()
|
||||
}
|
||||
|
||||
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
||||
func (s *Service) resync() error {
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signing := make([]SigningDomain, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector})
|
||||
}
|
||||
return s.odk.Rebuild(signing)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// FormatDomainExport identifies a single-domain export file (architecture.md §
|
||||
// Persistence).
|
||||
const FormatDomainExport = "selfpost-domain-export"
|
||||
|
||||
// DomainExport is the serialisable form of one sending domain, for moving it
|
||||
// between two independently running SelfPost instances (architecture.md §
|
||||
// Persistence). It carries the DKIM private key (so the published DNS record
|
||||
// stays valid) and each application's working password (so credentials
|
||||
// transfer without regeneration). The file is therefore as sensitive as a full
|
||||
// backup and must be handled as a secret.
|
||||
type DomainExport struct {
|
||||
Format string `json:"format"`
|
||||
Version string `json:"version"`
|
||||
Domain string `json:"domain"`
|
||||
DKIMSelector string `json:"dkim_selector"`
|
||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||
Applications []AppExport `json:"applications"`
|
||||
}
|
||||
|
||||
// AppExport is one application within a DomainExport.
|
||||
type AppExport struct {
|
||||
Login string `json:"login"`
|
||||
AddressMode string `json:"address_mode"`
|
||||
Addresses []string `json:"addresses,omitempty"` // list mode only
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Export builds the transferable representation of a domain: its DKIM key, its
|
||||
// selector and every application with its address mode and working password
|
||||
// (architecture.md § Persistence). The returned struct is marshalled to JSON
|
||||
// by the caller and offered as a secret download.
|
||||
func (s *Service) Export(id int64) (DomainExport, error) {
|
||||
d, err := s.store.GetDomain(id)
|
||||
if err != nil {
|
||||
return DomainExport{}, err
|
||||
}
|
||||
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
|
||||
if err != nil {
|
||||
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
|
||||
}
|
||||
apps, err := s.store.ListApplicationsByDomain(id)
|
||||
if err != nil {
|
||||
return DomainExport{}, err
|
||||
}
|
||||
exp := DomainExport{
|
||||
Format: FormatDomainExport,
|
||||
Version: buildinfo.Version,
|
||||
Domain: d.Name,
|
||||
DKIMSelector: d.DKIMSelector,
|
||||
DKIMPrivateKey: string(pem),
|
||||
Applications: make([]AppExport, 0, len(apps)),
|
||||
}
|
||||
for _, a := range apps {
|
||||
password, err := s.apps.Secret(a.Login)
|
||||
if err != nil {
|
||||
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
|
||||
}
|
||||
exp.Applications = append(exp.Applications, AppExport{
|
||||
Login: a.Login,
|
||||
AddressMode: a.AddressMode,
|
||||
Addresses: a.Addresses,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// Import re-creates a domain from an export file on this instance
|
||||
// (architecture.md § Persistence): it stores the imported DKIM key (so the
|
||||
// published DNS record needs no change), registers the domain and rebuilds the
|
||||
// OpenDKIM tables, then re-creates each application with its working password
|
||||
// and rebuilds the Postfix sender map.
|
||||
//
|
||||
// exp.Domain must already be normalised and validated by the caller
|
||||
// (security.md); the selector is checked for
|
||||
// config-injection safety here. A domain or login that already exists is
|
||||
// rejected (store.ErrDomainExists / store.ErrLoginExists) rather than merged.
|
||||
// If any step fails, everything the import created is rolled back, so a
|
||||
// partial import never leaves the instance in an inconsistent state.
|
||||
func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
||||
if exp.Format != FormatDomainExport {
|
||||
return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format)
|
||||
}
|
||||
if err := assertConfigSafe(exp.Domain, exp.DKIMSelector); err != nil {
|
||||
return store.Domain{}, err
|
||||
}
|
||||
|
||||
// Registry row first, so its UNIQUE constraint is the sole arbiter of a
|
||||
// duplicate domain before we touch the filesystem.
|
||||
d, err := s.store.AddDomain(exp.Domain, exp.DKIMSelector)
|
||||
if err != nil {
|
||||
return store.Domain{}, err // ErrDomainExists surfaces to the caller
|
||||
}
|
||||
|
||||
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
if err := s.resync(); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
|
||||
for _, a := range exp.Applications {
|
||||
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
||||
}
|
||||
}
|
||||
if err := s.apps.Resync(); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// importRollback best-effort tears down a partially imported domain by running
|
||||
// the normal deletion path, which clears the SASL accounts of any applications
|
||||
// already created, removes the registry rows (cascade), rebuilds both maps and
|
||||
// removes the DKIM key. Any error here is subordinate to the original failure
|
||||
// the caller returns.
|
||||
func (s *Service) importRollback(id int64) {
|
||||
_ = s.Delete(id)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
|
||||
// the import calls and hands back canned SASL secrets.
|
||||
type fakeApps struct {
|
||||
secrets map[string]string
|
||||
imported []importedApp
|
||||
importErr error
|
||||
}
|
||||
|
||||
type importedApp struct {
|
||||
domainID int64
|
||||
login string
|
||||
mode string
|
||||
addresses []string
|
||||
password string
|
||||
}
|
||||
|
||||
func (f *fakeApps) PurgeDomainSASL(int64) error { return nil }
|
||||
func (f *fakeApps) Resync() error { return nil }
|
||||
|
||||
func (f *fakeApps) Secret(login string) (string, error) {
|
||||
pw, ok := f.secrets[login]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no secret for %q", login)
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
func (f *fakeApps) ImportApplication(domainID int64, login, mode string, addresses []string, password string) error {
|
||||
if f.importErr != nil {
|
||||
return f.importErr
|
||||
}
|
||||
f.imported = append(f.imported, importedApp{domainID, login, mode, addresses, password})
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
|
||||
// a temp dir, with the OpenDKIM reload signal stubbed out.
|
||||
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.TempDir() + "/selfpost.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
odk := NewOpenDKIM(t.TempDir())
|
||||
odk.reload = func() error { return nil }
|
||||
return NewService(st, odk, apps, "selfpost"), odk
|
||||
}
|
||||
|
||||
func TestExportImportRoundTrip(t *testing.T) {
|
||||
// Source instance: a domain with two applications and their secrets.
|
||||
srcApps := &fakeApps{secrets: map[string]string{"mailer": "pw-mailer", "alerts": "pw-alerts"}}
|
||||
src, srcOdk := newTestService(t, srcApps)
|
||||
|
||||
d, err := src.Add("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatalf("add mailer: %v", err)
|
||||
}
|
||||
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||
t.Fatalf("add alerts: %v", err)
|
||||
}
|
||||
|
||||
exp, err := src.Export(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
if exp.Format != FormatDomainExport || exp.Domain != "example.com" || exp.DKIMSelector != "selfpost" {
|
||||
t.Fatalf("export header = %+v", exp)
|
||||
}
|
||||
if len(exp.Applications) != 2 {
|
||||
t.Fatalf("exported %d apps, want 2", len(exp.Applications))
|
||||
}
|
||||
srcKey, err := srcOdk.ExportKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("read source key: %v", err)
|
||||
}
|
||||
if exp.DKIMPrivateKey != string(srcKey) {
|
||||
t.Error("export DKIM key does not match the on-disk key")
|
||||
}
|
||||
|
||||
// Target instance: import the file.
|
||||
dstApps := &fakeApps{}
|
||||
dst, dstOdk := newTestService(t, dstApps)
|
||||
nd, err := dst.Import(exp)
|
||||
if err != nil {
|
||||
t.Fatalf("Import: %v", err)
|
||||
}
|
||||
|
||||
// Domain row landed with the exported selector.
|
||||
got, err := dst.Get(nd.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get imported domain: %v", err)
|
||||
}
|
||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||
t.Errorf("imported domain = %+v", got)
|
||||
}
|
||||
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
||||
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("read imported key: %v", err)
|
||||
}
|
||||
if string(dstKey) != string(srcKey) {
|
||||
t.Error("imported DKIM key differs from the source key")
|
||||
}
|
||||
// Applications were re-created with their working passwords.
|
||||
if len(dstApps.imported) != 2 {
|
||||
t.Fatalf("imported %d apps, want 2", len(dstApps.imported))
|
||||
}
|
||||
byLogin := map[string]importedApp{}
|
||||
for _, a := range dstApps.imported {
|
||||
byLogin[a.login] = a
|
||||
}
|
||||
if byLogin["mailer"].password != "pw-mailer" || byLogin["alerts"].password != "pw-alerts" {
|
||||
t.Errorf("imported passwords = %+v", dstApps.imported)
|
||||
}
|
||||
if byLogin["alerts"].mode != store.AddressModeList {
|
||||
t.Errorf("alerts mode = %q", byLogin["alerts"].mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsWrongFormat(t *testing.T) {
|
||||
dst, _ := newTestService(t, &fakeApps{})
|
||||
if _, err := dst.Import(DomainExport{Format: "nope", Domain: "example.com"}); err == nil {
|
||||
t.Error("Import accepted a non-export file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsDuplicateDomain(t *testing.T) {
|
||||
dst, _ := newTestService(t, &fakeApps{})
|
||||
if _, err := dst.Add("example.com"); err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
// A minimal but well-formed export of the same domain.
|
||||
src, srcOdk := newTestService(t, &fakeApps{})
|
||||
d, _ := src.Add("example.com")
|
||||
key, _ := srcOdk.ExportKey("example.com", "selfpost")
|
||||
exp := DomainExport{
|
||||
Format: FormatDomainExport, Domain: "example.com", DKIMSelector: "selfpost",
|
||||
DKIMPrivateKey: string(key),
|
||||
}
|
||||
_ = d
|
||||
if _, err := dst.Import(exp); !errors.Is(err, store.ErrDomainExists) {
|
||||
t.Errorf("Import duplicate = %v, want ErrDomainExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRollsBackOnAppFailure(t *testing.T) {
|
||||
// Build a valid export from a source instance.
|
||||
src, _ := newTestService(t, &fakeApps{secrets: map[string]string{"mailer": "pw"}})
|
||||
d, _ := src.Add("example.com")
|
||||
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatalf("add app: %v", err)
|
||||
}
|
||||
exp, err := src.Export(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
|
||||
// Target rejects the application import; the whole domain must roll back.
|
||||
dstApps := &fakeApps{importErr: errors.New("boom")}
|
||||
dst, dstOdk := newTestService(t, dstApps)
|
||||
if _, err := dst.Import(exp); err == nil {
|
||||
t.Fatal("Import succeeded despite an application failure")
|
||||
}
|
||||
// Domain row removed.
|
||||
domains, err := dst.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(domains) != 0 {
|
||||
t.Errorf("expected rollback to remove the domain, got %+v", domains)
|
||||
}
|
||||
// DKIM key removed.
|
||||
if _, err := dstOdk.ExportKey("example.com", "selfpost"); err == nil {
|
||||
t.Error("expected rollback to remove the imported DKIM key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certWarnDays is how close to expiry the certificate has to be before the
|
||||
// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
|
||||
// comfortably past the point where automatic renewal should have happened.
|
||||
const certWarnDays = 14
|
||||
|
||||
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
||||
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
||||
// the file is supplied by the reverse proxy through a read-only mount.
|
||||
type Certificate struct {
|
||||
Path string
|
||||
Subject string
|
||||
NotAfter time.Time
|
||||
DaysLeft int
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// CheckCertificate parses the leaf certificate at path and reports how much
|
||||
// validity is left. A missing or unparsable file is an error status rather than
|
||||
// an error return: the status page reports it in place, like every other check.
|
||||
func CheckCertificate(path string) Certificate {
|
||||
c := Certificate{Path: path}
|
||||
if path == "" {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
|
||||
return c
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.Status = StatusError
|
||||
c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
|
||||
return c
|
||||
}
|
||||
leaf, err := parseLeaf(data)
|
||||
if err != nil {
|
||||
c.Status = StatusError
|
||||
c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
|
||||
return c
|
||||
}
|
||||
|
||||
c.Subject = leaf.Subject.CommonName
|
||||
c.NotAfter = leaf.NotAfter
|
||||
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
|
||||
switch {
|
||||
case !time.Now().Before(leaf.NotAfter):
|
||||
c.Status = StatusError
|
||||
c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
|
||||
case c.DaysLeft < certWarnDays:
|
||||
c.Status = StatusWarn
|
||||
c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
|
||||
default:
|
||||
c.Status = StatusOK
|
||||
c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
|
||||
// the one whose validity clients see.
|
||||
func parseLeaf(data []byte) (*x509.Certificate, error) {
|
||||
for rest := data; len(rest) > 0; {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
return nil, fmt.Errorf("no CERTIFICATE block found")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package health reports the running container's own operating state for the
|
||||
// panel's status screen: the supervised processes, the TLS certificate Postfix
|
||||
// serves, and the milter sockets delivery depends on.
|
||||
//
|
||||
// Every check is read-only and reports a problem as a Status value rather than
|
||||
// an error return, so one broken component degrades a single line of the status
|
||||
// page instead of blanking the whole thing. The package also owns the Status
|
||||
// vocabulary shared with internal/dnscheck, so the panel renders every check —
|
||||
// local or DNS — through one set of badges.
|
||||
package health
|
||||
|
||||
// Status is the outcome of a single check, in the order the status page treats
|
||||
// them: unknown < ok < warn < error, worst wins for a group.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusUnknown means the check could not be performed at all (a missing
|
||||
// setting, an unreachable resolver) — not evidence of a problem.
|
||||
StatusUnknown Status = "unknown"
|
||||
// StatusOK means the checked component is in its expected state.
|
||||
StatusOK Status = "ok"
|
||||
// StatusWarn means something is off but mail still flows.
|
||||
StatusWarn Status = "warn"
|
||||
// StatusError means mail delivery is (or soon will be) affected.
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
// severity orders statuses so a group can report its worst member.
|
||||
func (s Status) severity() int {
|
||||
switch s {
|
||||
case StatusError:
|
||||
return 3
|
||||
case StatusWarn:
|
||||
return 2
|
||||
case StatusOK:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Worst returns the most severe of the given statuses, or StatusUnknown when
|
||||
// there are none. It is how the status page rolls a list of checks up into one
|
||||
// headline.
|
||||
func Worst(statuses ...Status) Status {
|
||||
worst := StatusUnknown
|
||||
for _, s := range statuses {
|
||||
if s.severity() > worst.severity() {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorstPicksMostSevere(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []Status
|
||||
want Status
|
||||
}{
|
||||
{nil, StatusUnknown},
|
||||
{[]Status{StatusOK, StatusOK}, StatusOK},
|
||||
{[]Status{StatusOK, StatusWarn}, StatusWarn},
|
||||
{[]Status{StatusWarn, StatusError, StatusOK}, StatusError},
|
||||
{[]Status{StatusUnknown, StatusOK}, StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Worst(c.in...); got != c.want {
|
||||
t.Errorf("Worst(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcesses(t *testing.T) {
|
||||
// Real supervisorctl output: column-aligned, one line per program.
|
||||
out := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix FATAL Exited too quickly (process log may have details)
|
||||
postfix-reload STOPPED Not started
|
||||
logrotate RUNNING pid 25, uptime 0:04:08
|
||||
`
|
||||
procs := parseProcesses(out)
|
||||
if len(procs) != 5 {
|
||||
t.Fatalf("parsed %d processes, want 5: %+v", len(procs), procs)
|
||||
}
|
||||
want := map[string]Status{
|
||||
"opendkim": StatusOK,
|
||||
"panel": StatusOK,
|
||||
"postfix": StatusError,
|
||||
"postfix-reload": StatusOK, // one-shot: idle is its healthy state
|
||||
"logrotate": StatusOK,
|
||||
}
|
||||
for _, p := range procs {
|
||||
if want[p.Name] != p.Status {
|
||||
t.Errorf("%s (%s): status %q, want %q", p.Name, p.State, p.Status, want[p.Name])
|
||||
}
|
||||
}
|
||||
if procs[0].Detail != "pid 21, uptime 0:04:10" {
|
||||
t.Errorf("detail = %q", procs[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcessesSkipsNonStatusLines(t *testing.T) {
|
||||
out := `error: <class 'socket.error'>, [Errno 2] No such file or directory
|
||||
unix:///run/supervisor.sock refused connection
|
||||
`
|
||||
if procs := parseProcesses(out); len(procs) != 0 {
|
||||
t.Errorf("error output parsed as processes: %+v", procs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
valid := filepath.Join(dir, "valid.pem")
|
||||
writeCert(t, valid, "mail.example.com", 90*24*time.Hour)
|
||||
if got := CheckCertificate(valid); got.Status != StatusOK {
|
||||
t.Errorf("valid certificate: status %q (%s)", got.Status, got.Detail)
|
||||
} else if got.Subject != "mail.example.com" {
|
||||
t.Errorf("subject = %q", got.Subject)
|
||||
}
|
||||
|
||||
soon := filepath.Join(dir, "soon.pem")
|
||||
writeCert(t, soon, "mail.example.com", 3*24*time.Hour)
|
||||
if got := CheckCertificate(soon); got.Status != StatusWarn {
|
||||
t.Errorf("nearly expired certificate: status %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
|
||||
expired := filepath.Join(dir, "expired.pem")
|
||||
writeCert(t, expired, "mail.example.com", -24*time.Hour)
|
||||
if got := CheckCertificate(expired); got.Status != StatusError {
|
||||
t.Errorf("expired certificate: status %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
|
||||
if got := CheckCertificate(filepath.Join(dir, "absent.pem")); got.Status != StatusError {
|
||||
t.Errorf("missing certificate: status %q", got.Status)
|
||||
}
|
||||
|
||||
junk := filepath.Join(dir, "junk.pem")
|
||||
if err := os.WriteFile(junk, []byte("not a certificate\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CheckCertificate(junk); got.Status != StatusError {
|
||||
t.Errorf("unparsable certificate: status %q", got.Status)
|
||||
}
|
||||
|
||||
if got := CheckCertificate(""); got.Status != StatusUnknown {
|
||||
t.Errorf("unconfigured certificate: status %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSocket(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "opendkim.sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
|
||||
t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
|
||||
missing := filepath.Join(dir, "journal.sock")
|
||||
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
|
||||
t.Errorf("missing optional socket: status %q", got.Status)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
|
||||
t.Errorf("missing required socket: status %q", got.Status)
|
||||
}
|
||||
|
||||
plain := filepath.Join(dir, "plain")
|
||||
if err := os.WriteFile(plain, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
|
||||
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCert writes a self-signed certificate expiring after validFor (negative
|
||||
// for an already-expired one).
|
||||
func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(validFor),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLivenessFromParsedProcesses(t *testing.T) {
|
||||
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix RUNNING pid 23, uptime 0:04:08
|
||||
postfix-reload STOPPED Not started
|
||||
`
|
||||
procs := parseProcesses(allRunning)
|
||||
for _, p := range procs {
|
||||
if mailPathPrograms[p.Name] && p.Status != StatusOK {
|
||||
t.Fatalf("%s should be ok for liveness, got %q", p.Name, p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix FATAL Exited too quickly
|
||||
`
|
||||
procs = parseProcesses(postfixDead)
|
||||
for _, p := range procs {
|
||||
if p.Name == "postfix" && p.Status == StatusOK {
|
||||
t.Fatal("postfix FATAL should not grade as OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mailPathPrograms are the supervised processes whose absence means the
|
||||
// container should not report healthy to orchestrators.
|
||||
var mailPathPrograms = map[string]bool{
|
||||
"opendkim": true,
|
||||
"panel": true,
|
||||
"postfix": true,
|
||||
}
|
||||
|
||||
// Liveness reports whether the mail path is healthy enough for container
|
||||
// probes. It requires opendkim, panel, and postfix to be RUNNING under
|
||||
// supervisord.
|
||||
func Liveness() error {
|
||||
procs, err := Processes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := make(map[string]Status, len(mailPathPrograms))
|
||||
for _, p := range procs {
|
||||
if mailPathPrograms[p.Name] {
|
||||
seen[p.Name] = p.Status
|
||||
}
|
||||
}
|
||||
|
||||
var unhealthy []string
|
||||
for name := range mailPathPrograms {
|
||||
switch seen[name] {
|
||||
case StatusOK:
|
||||
case StatusUnknown:
|
||||
unhealthy = append(unhealthy, name+": missing")
|
||||
default:
|
||||
unhealthy = append(unhealthy, fmt.Sprintf("%s: %s", name, seen[name]))
|
||||
}
|
||||
}
|
||||
if len(unhealthy) > 0 {
|
||||
return fmt.Errorf("unhealthy: %s", strings.Join(unhealthy, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// machineSampleWindow is the longest gap between two readings that still counts
|
||||
// as "now". CPU busy time and network throughput are differences between two
|
||||
// samples, and the status fragment polls every five seconds — but a page opened
|
||||
// after the panel sat idle for an hour would otherwise average that whole hour
|
||||
// and present it as the current load. Past this the reading only re-baselines,
|
||||
// and the rates say they are still being measured.
|
||||
const machineSampleWindow = time.Minute
|
||||
|
||||
// Thresholds for the two resources whose exhaustion actually threatens mail: a
|
||||
// fully busy processor slows queue processing, and a machine out of memory has
|
||||
// its processes killed. Network throughput has no comparable threshold — what
|
||||
// counts as a lot depends entirely on the link — so it is reported, not graded.
|
||||
const (
|
||||
cpuWarnPct = 90.0
|
||||
memWarnPct = 90.0
|
||||
memErrorPct = 97.0
|
||||
)
|
||||
|
||||
// Machine is the resource usage of the machine this container runs on: the
|
||||
// status page's answer to "is the server itself under strain", next to the
|
||||
// component checks that answer "is mail flowing".
|
||||
//
|
||||
// The numbers come from the kernel's /proc filesystem, which a container shares
|
||||
// with its host unless it was started with its own namespaces — so CPU and
|
||||
// memory describe the host, while /proc/net/dev describes whatever network
|
||||
// namespace the container is in (its own veth under the default bridge, the
|
||||
// host's interfaces under network_mode: host).
|
||||
type Machine struct {
|
||||
CPU CPU
|
||||
Memory Memory
|
||||
Network Network
|
||||
// Window is the interval the rates were measured over; zero until a
|
||||
// second reading exists.
|
||||
Window time.Duration
|
||||
Status Status
|
||||
}
|
||||
|
||||
// WindowText names the sampling interval for the card's description.
|
||||
func (m Machine) WindowText() string {
|
||||
if m.Window <= 0 {
|
||||
return ""
|
||||
}
|
||||
return m.Window.Round(time.Second).String()
|
||||
}
|
||||
|
||||
// CPU is processor load over the sampling window.
|
||||
type CPU struct {
|
||||
// Measured is false until two readings exist to compare; BusyPct means
|
||||
// nothing until it is true.
|
||||
Measured bool
|
||||
BusyPct float64
|
||||
Cores int
|
||||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
||||
// could be read. Unlike BusyPct it needs no previous sample, so it is
|
||||
// there on the very first page load.
|
||||
Load [3]float64
|
||||
HasLoad bool
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Percent is BusyPct as a whole number, for the <meter> element's value
|
||||
// attribute. The bar carries its value in an attribute rather than a width in a
|
||||
// style attribute because the panel's CSP has no inline-style exemption
|
||||
// (security.md).
|
||||
func (c CPU) Percent() int { return percent(c.BusyPct) }
|
||||
|
||||
// BusyText is the reading as it appears beside the bar.
|
||||
func (c CPU) BusyText() string { return fmt.Sprintf("%d%%", c.Percent()) }
|
||||
|
||||
// Memory is main memory (and swap, where the machine has any) at the moment of
|
||||
// the reading. Unlike CPU and network it is a level, not a rate, so a single
|
||||
// reading is enough and it is never in the "measuring" state.
|
||||
type Memory struct {
|
||||
Measured bool
|
||||
TotalBytes uint64
|
||||
AvailableBytes uint64
|
||||
UsedBytes uint64
|
||||
UsedPct float64
|
||||
SwapTotalBytes uint64
|
||||
SwapUsedBytes uint64
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Percent, UsedText and TotalText render the reading for the template; see
|
||||
// CPU.Percent for why the bar's value travels as an attribute.
|
||||
func (m Memory) Percent() int { return percent(m.UsedPct) }
|
||||
func (m Memory) UsedText() string { return humanBytes(m.UsedBytes) }
|
||||
func (m Memory) TotalText() string { return humanBytes(m.TotalBytes) }
|
||||
func (m Memory) PctText() string { return fmt.Sprintf("%d%%", m.Percent()) }
|
||||
|
||||
// Interface is one network interface's traffic: the counters since the
|
||||
// interface came up, and the throughput over the sampling window.
|
||||
type Interface struct {
|
||||
Name string
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
RxRate float64 // bytes per second, valid when the Network is Measured
|
||||
TxRate float64
|
||||
Measured bool
|
||||
}
|
||||
|
||||
func (i Interface) InText() string { return humanBytes(i.RxBytes) }
|
||||
func (i Interface) OutText() string { return humanBytes(i.TxBytes) }
|
||||
func (i Interface) InRateText() string { return humanRate(i.RxRate) }
|
||||
func (i Interface) OutRateText() string { return humanRate(i.TxRate) }
|
||||
|
||||
// Network is the traffic across every interface that has carried any, loopback
|
||||
// excluded — loopback traffic is the container talking to itself (the panel to
|
||||
// SQLite, Postfix to its milters) and says nothing about the link.
|
||||
type Network struct {
|
||||
Measured bool
|
||||
Interfaces []Interface
|
||||
RxRate float64
|
||||
TxRate float64
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (n Network) InRateText() string { return humanRate(n.RxRate) }
|
||||
func (n Network) OutRateText() string { return humanRate(n.TxRate) }
|
||||
|
||||
// MachineSampler reads those counters. Its zero value is ready to use and it is
|
||||
// safe for concurrent use, but one sampler has to be shared by every caller:
|
||||
// the rates are measured against the reading the previous call left behind, so
|
||||
// a fresh sampler per request would never have anything to compare against.
|
||||
type MachineSampler struct {
|
||||
// procRoot replaces /proc in tests; empty means the real one.
|
||||
procRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
prevAt time.Time
|
||||
prevCPU cpuTimes
|
||||
prevNet map[string]netCounters
|
||||
}
|
||||
|
||||
func (m *MachineSampler) root() string {
|
||||
if m.procRoot == "" {
|
||||
return "/proc"
|
||||
}
|
||||
return m.procRoot
|
||||
}
|
||||
|
||||
// cpuTimes is the aggregate of /proc/stat's "cpu" line: all time accounted for,
|
||||
// and the part of it the processor spent doing nothing.
|
||||
type cpuTimes struct {
|
||||
total uint64
|
||||
idle uint64
|
||||
}
|
||||
|
||||
// netCounters is one interface's byte counters from /proc/net/dev.
|
||||
type netCounters struct {
|
||||
rx uint64
|
||||
tx uint64
|
||||
}
|
||||
|
||||
// Sample reads the current counters and reports usage since the previous call.
|
||||
// Like every other check here it never fails: a counter that cannot be read
|
||||
// becomes an unknown status with an explanation, so a kernel that does not
|
||||
// publish one of these files (or a panel run outside Linux for development)
|
||||
// costs one line of the card rather than the page.
|
||||
func (m *MachineSampler) Sample() Machine {
|
||||
root := m.root()
|
||||
now := time.Now()
|
||||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
||||
netNow, netErr := readNetDev(root)
|
||||
|
||||
m.mu.Lock()
|
||||
prevAt, prevCPU, prevNet := m.prevAt, m.prevCPU, m.prevNet
|
||||
if cpuErr == nil {
|
||||
m.prevCPU = cpuNow
|
||||
}
|
||||
if netErr == nil {
|
||||
m.prevNet = netNow
|
||||
}
|
||||
if cpuErr == nil || netErr == nil {
|
||||
m.prevAt = now
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// A window of zero (two calls in the same instant) would divide by zero;
|
||||
// one longer than machineSampleWindow is no longer a description of now.
|
||||
window := now.Sub(prevAt)
|
||||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||||
|
||||
mach := Machine{
|
||||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
||||
Memory: readMemory(root),
|
||||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||||
}
|
||||
if fresh {
|
||||
mach.Window = window
|
||||
}
|
||||
mach.Status = Worst(mach.CPU.Status, mach.Memory.Status, mach.Network.Status)
|
||||
return mach
|
||||
}
|
||||
|
||||
// cpuUsage grades the processor over the window. The load average is reported
|
||||
// alongside it because the two answer different questions — how busy the
|
||||
// processor was, and how many tasks were waiting for it — and a machine can
|
||||
// look idle while work queues up behind a slow disk.
|
||||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
||||
c := CPU{Cores: cores}
|
||||
if !isZeroLoad(load) {
|
||||
c.Load, c.HasLoad = load, true
|
||||
}
|
||||
if err != nil {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||||
return c
|
||||
}
|
||||
if !fresh || cur.total <= prev.total {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
||||
return c
|
||||
}
|
||||
|
||||
total := cur.total - prev.total
|
||||
idle := uint64(0)
|
||||
if cur.idle > prev.idle {
|
||||
idle = cur.idle - prev.idle
|
||||
}
|
||||
if idle > total {
|
||||
idle = total
|
||||
}
|
||||
c.Measured = true
|
||||
c.BusyPct = 100 * float64(total-idle) / float64(total)
|
||||
|
||||
if c.BusyPct >= cpuWarnPct {
|
||||
c.Status = StatusWarn
|
||||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
||||
} else {
|
||||
c.Status = StatusOK
|
||||
c.Detail = c.contextText()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// contextText is the CPU's supporting figures: what the percentage is a
|
||||
// percentage of, and how deep the run queue is.
|
||||
func (c CPU) contextText() string {
|
||||
var parts []string
|
||||
if c.Cores > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
||||
}
|
||||
if c.HasLoad {
|
||||
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||||
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
|
||||
// MemFree on a healthy machine is near zero and would report a permanent
|
||||
// emergency. MemAvailable is the kernel's own estimate of what a new workload
|
||||
// could actually get.
|
||||
func readMemory(root string) Memory {
|
||||
var m Memory
|
||||
fields, err := readMeminfo(root)
|
||||
if err != nil {
|
||||
m.Status = StatusUnknown
|
||||
m.Detail = "The kernel's memory counters (/proc/meminfo) could not be read here."
|
||||
return m
|
||||
}
|
||||
total, available := fields["MemTotal"], fields["MemAvailable"]
|
||||
if total == 0 {
|
||||
m.Status = StatusUnknown
|
||||
m.Detail = "/proc/meminfo does not report a total memory size."
|
||||
return m
|
||||
}
|
||||
if available > total {
|
||||
available = total
|
||||
}
|
||||
|
||||
m.Measured = true
|
||||
m.TotalBytes = total
|
||||
m.AvailableBytes = available
|
||||
m.UsedBytes = total - available
|
||||
m.UsedPct = 100 * float64(m.UsedBytes) / float64(total)
|
||||
m.SwapTotalBytes = fields["SwapTotal"]
|
||||
if swapFree := fields["SwapFree"]; m.SwapTotalBytes > swapFree {
|
||||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
||||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
||||
if m.SwapTotalBytes > 0 {
|
||||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||||
}
|
||||
switch {
|
||||
case m.UsedPct >= memErrorPct:
|
||||
m.Status = StatusError
|
||||
m.Detail = detail + " Memory is exhausted; the kernel kills processes to reclaim it, and Postfix or the panel are candidates."
|
||||
case m.UsedPct >= memWarnPct:
|
||||
m.Status = StatusWarn
|
||||
m.Detail = detail + " Little headroom left."
|
||||
default:
|
||||
m.Status = StatusOK
|
||||
m.Detail = detail
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// networkUsage turns two readings of the interface counters into throughput.
|
||||
// It never grades: there is no usage figure that is wrong for a mail server, so
|
||||
// the row is informational and only reports "unknown" when the counters are
|
||||
// unreadable.
|
||||
func networkUsage(prev, cur map[string]netCounters, window time.Duration, fresh bool, err error) Network {
|
||||
var n Network
|
||||
if err != nil {
|
||||
n.Status = StatusUnknown
|
||||
n.Detail = "The kernel's network counters (/proc/net/dev) could not be read here."
|
||||
return n
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(cur))
|
||||
for name := range cur {
|
||||
names = append(names, name)
|
||||
}
|
||||
// Map order is random, and this table is re-rendered every few seconds:
|
||||
// without a sort the rows would shuffle under the reader.
|
||||
sort.Strings(names)
|
||||
|
||||
n.Measured = fresh
|
||||
for _, name := range names {
|
||||
c := cur[name]
|
||||
// An interface that has never carried a byte is a veth or a bridge
|
||||
// the deployment happens to have, not part of the mail path.
|
||||
if c.rx == 0 && c.tx == 0 {
|
||||
continue
|
||||
}
|
||||
iface := Interface{Name: name, RxBytes: c.rx, TxBytes: c.tx, Measured: fresh}
|
||||
if fresh {
|
||||
p := prev[name]
|
||||
secs := window.Seconds()
|
||||
// Counters only go up; a drop means the interface (or the
|
||||
// container) was recreated, so there is no rate to report.
|
||||
if c.rx >= p.rx {
|
||||
iface.RxRate = float64(c.rx-p.rx) / secs
|
||||
}
|
||||
if c.tx >= p.tx {
|
||||
iface.TxRate = float64(c.tx-p.tx) / secs
|
||||
}
|
||||
n.RxRate += iface.RxRate
|
||||
n.TxRate += iface.TxRate
|
||||
}
|
||||
n.Interfaces = append(n.Interfaces, iface)
|
||||
}
|
||||
|
||||
n.Status = StatusOK
|
||||
switch {
|
||||
case len(n.Interfaces) == 0:
|
||||
n.Detail = "No interface outside loopback has carried any traffic."
|
||||
case !fresh:
|
||||
n.Detail = "Measuring — this reading sets the baseline; the next refresh has the throughput."
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readCPUTimes returns the aggregate processor times and the number of cores
|
||||
// from /proc/stat. The times are in USER_HZ ticks, which cancel out because
|
||||
// only their ratio is used.
|
||||
func readCPUTimes(root string) (cpuTimes, int, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "stat"))
|
||||
if err != nil {
|
||||
return cpuTimes{}, 0, err
|
||||
}
|
||||
var (
|
||||
times cpuTimes
|
||||
cores int
|
||||
found bool
|
||||
)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||||
continue
|
||||
}
|
||||
if fields[0] != "cpu" {
|
||||
cores++ // cpu0, cpu1, … — one line per core
|
||||
continue
|
||||
}
|
||||
// user nice system idle iowait irq softirq steal (guest fields are
|
||||
// already counted inside user/nice, so they are left out).
|
||||
for i, f := range fields[1:] {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: unreadable cpu time %q", f)
|
||||
}
|
||||
times.total += v
|
||||
// idle plus iowait: both are time the processor had nothing to
|
||||
// run, and separating them tells the reader nothing here.
|
||||
if i == 3 || i == 4 {
|
||||
times.idle += v
|
||||
}
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: no aggregate cpu line")
|
||||
}
|
||||
return times, cores, nil
|
||||
}
|
||||
|
||||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
||||
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||||
// than an error.
|
||||
func readLoadAvg(root string) [3]float64 {
|
||||
var load [3]float64
|
||||
data, err := os.ReadFile(filepath.Join(root, "loadavg"))
|
||||
if err != nil {
|
||||
return load
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return load
|
||||
}
|
||||
for i := range load {
|
||||
v, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err != nil {
|
||||
return [3]float64{}
|
||||
}
|
||||
load[i] = v
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
func isZeroLoad(load [3]float64) bool {
|
||||
return load == [3]float64{}
|
||||
}
|
||||
|
||||
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
|
||||
// The file reports kB (kibibytes, despite the label) for these fields.
|
||||
func readMeminfo(root string) (map[string]uint64, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := map[string]bool{"MemTotal": true, "MemAvailable": true, "SwapTotal": true, "SwapFree": true}
|
||||
out := make(map[string]uint64, len(want))
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
if !ok || !want[name] {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
|
||||
v *= 1024
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("/proc/meminfo: no readable fields")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
|
||||
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
|
||||
// name is separated by a colon, which may or may not have a space after it, so
|
||||
// the split is on the colon and not on whitespace.
|
||||
func readNetDev(root string) (map[string]netCounters, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]netCounters)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" || name == "lo" || strings.Contains(name, " ") {
|
||||
continue // header lines carry no name, or several words
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out[name] = netCounters{rx: rx, tx: tx}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// percent rounds a 0–100 reading to a whole number and clamps it, so a bar's
|
||||
// value attribute is always inside the range the element declares.
|
||||
func percent(v float64) int {
|
||||
switch {
|
||||
case v <= 0:
|
||||
return 0
|
||||
case v >= 100:
|
||||
return 100
|
||||
default:
|
||||
return int(v + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// humanBytes renders a byte count in the binary units memory and traffic are
|
||||
// conventionally read in.
|
||||
func humanBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// humanRate renders throughput in bytes per second, to match the totals beside
|
||||
// it rather than the bits per second a link is sold in.
|
||||
func humanRate(perSec float64) string {
|
||||
if perSec < 0 {
|
||||
perSec = 0
|
||||
}
|
||||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||||
}
|
||||
|
||||
// joinDetail joins the non-empty parts of a detail line.
|
||||
func joinDetail(parts ...string) string {
|
||||
var kept []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, " ")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProc writes a /proc-shaped directory the sampler can be pointed at, so
|
||||
// the parsing and the arithmetic are tested against known counters instead of
|
||||
// whatever the machine running the tests happens to be doing.
|
||||
func fakeProc(t *testing.T, stat, meminfo, loadavg, netdev string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("stat", stat)
|
||||
write("meminfo", meminfo)
|
||||
write("loadavg", loadavg)
|
||||
write(filepath.Join("net", "dev"), netdev)
|
||||
return dir
|
||||
}
|
||||
|
||||
const meminfoSample = `MemTotal: 4194304 kB
|
||||
MemFree: 131072 kB
|
||||
MemAvailable: 2097152 kB
|
||||
Buffers: 262144 kB
|
||||
SwapTotal: 1048576 kB
|
||||
SwapFree: 524288 kB
|
||||
`
|
||||
|
||||
const netdevSample = `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 500000 1000 0 0 0 0 0 0 500000 1000 0 0 0 0 0 0
|
||||
eth0: 1048576 2000 0 0 0 0 0 0 524288 1500 0 0 0 0 0 0
|
||||
veth9: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
`
|
||||
|
||||
func TestCPUUsageOverTwoReadings(t *testing.T) {
|
||||
// 1000 ticks pass, 250 of them idle: 75% busy.
|
||||
prev := cpuTimes{total: 10000, idle: 8000}
|
||||
cur := cpuTimes{total: 11000, idle: 8250}
|
||||
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||
if !got.Measured {
|
||||
t.Fatalf("reading not marked measured: %+v", got)
|
||||
}
|
||||
if got.Percent() != 75 {
|
||||
t.Errorf("busy = %.2f%% (%s), want 75%%", got.BusyPct, got.BusyText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok", got.Status)
|
||||
}
|
||||
if got.Cores != 4 || !got.HasLoad {
|
||||
t.Errorf("cores/load not reported: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
|
||||
if got.Status != StatusWarn {
|
||||
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
||||
}
|
||||
if got.HasLoad {
|
||||
t.Error("a missing load average should not be reported as zeros")
|
||||
}
|
||||
}
|
||||
|
||||
// The first reading has nothing to compare against, and one taken after a long
|
||||
// idle stretch would describe that stretch rather than now. Both must report
|
||||
// "unknown" rather than a number the reader would take for the current load.
|
||||
func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
prev, cur cpuTimes
|
||||
fresh bool
|
||||
}{
|
||||
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
||||
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
||||
} {
|
||||
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
|
||||
if got.Measured || got.Status != StatusUnknown {
|
||||
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMemory(t *testing.T) {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
||||
got := readMemory(dir)
|
||||
if !got.Measured {
|
||||
t.Fatalf("memory not measured: %+v", got)
|
||||
}
|
||||
if got.TotalBytes != 4*1024*1024*1024 {
|
||||
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
||||
}
|
||||
// 4 GiB total, 2 GiB available to new work: half used, cache included in
|
||||
// what is available.
|
||||
if got.Percent() != 50 {
|
||||
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok (%s)", got.Status, got.Detail)
|
||||
}
|
||||
if got.SwapUsedBytes != 512*1024*1024 {
|
||||
t.Errorf("swap used = %d bytes, want 512 MiB", got.SwapUsedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMemoryGrades(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
available string
|
||||
want Status
|
||||
}{
|
||||
{"plenty free", "MemAvailable: 2097152 kB\n", StatusOK},
|
||||
{"little headroom", "MemAvailable: 209715 kB\n", StatusWarn},
|
||||
{"exhausted", "MemAvailable: 41943 kB\n", StatusError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", "MemTotal: 4194304 kB\n"+c.available, "", netdevSample)
|
||||
if got := readMemory(dir); got.Status != c.want {
|
||||
t.Errorf("%s: status %q, want %q (%s)", c.name, got.Status, c.want, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
if got := readMemory(t.TempDir()); got.Status != StatusUnknown || got.Measured {
|
||||
t.Errorf("missing /proc/meminfo: status %q measured=%v", got.Status, got.Measured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNetDevSkipsLoopbackAndHeaders(t *testing.T) {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "", netdevSample)
|
||||
got, err := readNetDev(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readNetDev: %v", err)
|
||||
}
|
||||
if _, ok := got["lo"]; ok {
|
||||
t.Error("loopback is counted as network traffic")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d interfaces, want eth0 and veth9: %+v", len(got), got)
|
||||
}
|
||||
if got["eth0"].rx != 1048576 || got["eth0"].tx != 524288 {
|
||||
t.Errorf("eth0 counters = %+v", got["eth0"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkUsageRates(t *testing.T) {
|
||||
prev := map[string]netCounters{"eth0": {rx: 1000, tx: 500}}
|
||||
cur := map[string]netCounters{
|
||||
"eth0": {rx: 11000, tx: 5500},
|
||||
"veth9": {rx: 0, tx: 0}, // never carried anything: not shown
|
||||
}
|
||||
got := networkUsage(prev, cur, 5*time.Second, true, nil)
|
||||
if len(got.Interfaces) != 1 || got.Interfaces[0].Name != "eth0" {
|
||||
t.Fatalf("interfaces = %+v, want eth0 only", got.Interfaces)
|
||||
}
|
||||
// 10000 bytes in and 5000 out over five seconds.
|
||||
if got.RxRate != 2000 || got.TxRate != 1000 {
|
||||
t.Errorf("rates = %.0f in / %.0f out, want 2000/1000", got.RxRate, got.TxRate)
|
||||
}
|
||||
if got.InRateText() != "2.0 KiB/s" {
|
||||
t.Errorf("in rate text = %q", got.InRateText())
|
||||
}
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok — throughput is reported, not graded", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A recreated container (or interface) resets the counters; the drop must not
|
||||
// be reported as a huge negative or wrapped-around rate.
|
||||
func TestNetworkUsageIgnoresCounterResets(t *testing.T) {
|
||||
prev := map[string]netCounters{"eth0": {rx: 1_000_000, tx: 900_000}}
|
||||
cur := map[string]netCounters{"eth0": {rx: 1000, tx: 900}}
|
||||
got := networkUsage(prev, cur, 5*time.Second, true, nil)
|
||||
if got.RxRate != 0 || got.TxRate != 0 {
|
||||
t.Errorf("rates after a counter reset = %.0f/%.0f, want 0/0", got.RxRate, got.TxRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkUsageWithoutAPreviousReading(t *testing.T) {
|
||||
cur := map[string]netCounters{"eth0": {rx: 1000, tx: 500}}
|
||||
got := networkUsage(nil, cur, 0, false, nil)
|
||||
if got.Measured {
|
||||
t.Error("first reading reported as measured")
|
||||
}
|
||||
if len(got.Interfaces) != 1 || got.Interfaces[0].InText() != "1000 B" {
|
||||
t.Errorf("totals should be shown even before a rate exists: %+v", got.Interfaces)
|
||||
}
|
||||
if got.Detail == "" {
|
||||
t.Error("no explanation for the missing rates")
|
||||
}
|
||||
|
||||
if bad := networkUsage(nil, nil, 0, false, os.ErrNotExist); bad.Status != StatusUnknown {
|
||||
t.Errorf("unreadable /proc/net/dev: status %q, want unknown", bad.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through the sampler: the first call baselines, the second reports.
|
||||
func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
|
||||
dir := fakeProc(t,
|
||||
"cpu 1000 0 500 8000 500 0 0 0\ncpu0 500 0 250 4000 250 0 0 0\ncpu1 500 0 250 4000 250 0 0 0\n",
|
||||
meminfoSample, "0.42 0.31 0.20 2/300 1234\n", netdevSample)
|
||||
m := &MachineSampler{procRoot: dir}
|
||||
|
||||
first := m.Sample()
|
||||
if first.CPU.Measured || first.CPU.Status != StatusUnknown {
|
||||
t.Errorf("first sample reported a CPU figure: %+v", first.CPU)
|
||||
}
|
||||
if !first.Memory.Measured {
|
||||
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
||||
}
|
||||
if first.CPU.Cores != 2 {
|
||||
t.Errorf("cores = %d, want 2", first.CPU.Cores)
|
||||
}
|
||||
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
||||
t.Errorf("load average not read: %+v", first.CPU.Load)
|
||||
}
|
||||
// Unknown checks must not drag the card into a warning.
|
||||
if first.Status != StatusOK {
|
||||
t.Errorf("overall machine status = %q, want ok", first.Status)
|
||||
}
|
||||
|
||||
// Second reading: 1000 more ticks, 750 of them idle → 25% busy.
|
||||
if err := os.WriteFile(filepath.Join(dir, "stat"),
|
||||
[]byte("cpu 1250 0 500 8750 500 0 0 0\ncpu0 625 0 250 4375 250 0 0 0\ncpu1 625 0 250 4375 250 0 0 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := m.Sample()
|
||||
if !second.CPU.Measured {
|
||||
t.Fatalf("second sample still unmeasured: %+v", second.CPU)
|
||||
}
|
||||
if second.CPU.Percent() != 25 {
|
||||
t.Errorf("busy = %s, want 25%%", second.CPU.BusyText())
|
||||
}
|
||||
if !second.Network.Measured || second.Window <= 0 {
|
||||
t.Errorf("network window not established: measured=%v window=%v", second.Network.Measured, second.Window)
|
||||
}
|
||||
if second.WindowText() == "" {
|
||||
t.Error("no sampling window to show on the card")
|
||||
}
|
||||
}
|
||||
|
||||
// Outside Linux — a developer running the panel on their own machine — there is
|
||||
// no /proc at all. Every metric must degrade to "unknown" rather than failing
|
||||
// the status page.
|
||||
func TestMachineSamplerWithoutProc(t *testing.T) {
|
||||
m := &MachineSampler{procRoot: filepath.Join(t.TempDir(), "absent")}
|
||||
got := m.Sample()
|
||||
if got.Status != StatusUnknown {
|
||||
t.Errorf("status = %q, want unknown", got.Status)
|
||||
}
|
||||
for name, st := range map[string]Status{"cpu": got.CPU.Status, "memory": got.Memory.Status, "network": got.Network.Status} {
|
||||
if st != StatusUnknown {
|
||||
t.Errorf("%s status = %q, want unknown", name, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
cases := map[uint64]string{
|
||||
0: "0 B",
|
||||
999: "999 B",
|
||||
1024: "1.0 KiB",
|
||||
1536: "1.5 KiB",
|
||||
4 * 1024 * 1024 * 1024: "4.0 GiB",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := humanBytes(in); got != want {
|
||||
t.Errorf("humanBytes(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := humanRate(0); got != "0 B/s" {
|
||||
t.Errorf("humanRate(0) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supervisorConf is the supervisord configuration the panel's other control
|
||||
// calls already address (see internal/postfix and internal/domain).
|
||||
const supervisorConf = "/etc/supervisor/supervisord.conf"
|
||||
|
||||
// oneShotPrograms are supervisord entries that are meant to sit idle: they are
|
||||
// started on demand and exit immediately, so STOPPED/EXITED is their healthy
|
||||
// state rather than a fault (see build/supervisord.conf).
|
||||
var oneShotPrograms = map[string]bool{
|
||||
"postfix-reload": true,
|
||||
}
|
||||
|
||||
// Process is one supervised program as supervisord reports it.
|
||||
type Process struct {
|
||||
Name string
|
||||
State string // supervisord's own state word, e.g. RUNNING
|
||||
Detail string // the rest of the line: pid/uptime, or exit information
|
||||
Status Status
|
||||
}
|
||||
|
||||
// Processes returns the state of every supervised program (architecture.md §
|
||||
// Image and processes — the three processes plus the reload/cert/logrotate
|
||||
// helpers).
|
||||
//
|
||||
// The command takes fixed arguments and no user input, so it never goes through
|
||||
// a shell (security.md). `supervisorctl status` deliberately exits non-zero when
|
||||
// some program is not running, so the output is parsed first and the exit status
|
||||
// only matters when nothing could be parsed from it.
|
||||
func Processes() ([]Process, error) {
|
||||
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
|
||||
out, err := cmd.CombinedOutput()
|
||||
procs := parseProcesses(string(out))
|
||||
if len(procs) == 0 {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("supervisorctl status: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil, fmt.Errorf("supervisorctl status: no programs reported")
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
|
||||
// supervisorStates are the state words supervisord prints. Lines whose second
|
||||
// field is not one of them are not status lines (banners, error text) and are
|
||||
// skipped, so unexpected output cannot masquerade as a process.
|
||||
var supervisorStates = map[string]bool{
|
||||
"STOPPED": true,
|
||||
"STARTING": true,
|
||||
"RUNNING": true,
|
||||
"BACKOFF": true,
|
||||
"STOPPING": true,
|
||||
"EXITED": true,
|
||||
"FATAL": true,
|
||||
"UNKNOWN": true,
|
||||
}
|
||||
|
||||
// parseProcesses turns supervisorctl's tabular output into Process values. Each
|
||||
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
|
||||
func parseProcesses(out string) []Process {
|
||||
var procs []Process
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !supervisorStates[fields[1]] {
|
||||
continue
|
||||
}
|
||||
name, state := fields[0], fields[1]
|
||||
procs = append(procs, Process{
|
||||
Name: name,
|
||||
State: state,
|
||||
Detail: strings.Join(fields[2:], " "),
|
||||
Status: processStatus(name, state),
|
||||
})
|
||||
}
|
||||
return procs
|
||||
}
|
||||
|
||||
// processStatus grades a supervisord state. A one-shot program that is not
|
||||
// running is healthy; anything else that is not RUNNING means a component of
|
||||
// the mail path is down or flapping.
|
||||
func processStatus(name, state string) Status {
|
||||
switch state {
|
||||
case "RUNNING":
|
||||
return StatusOK
|
||||
case "STARTING", "STOPPING":
|
||||
return StatusWarn
|
||||
case "STOPPED", "EXITED":
|
||||
if oneShotPrograms[name] {
|
||||
return StatusOK
|
||||
}
|
||||
return StatusError
|
||||
default: // BACKOFF, FATAL, UNKNOWN
|
||||
return StatusError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Socket is the state of one milter socket Postfix connects to.
|
||||
type Socket struct {
|
||||
Name string
|
||||
Path string
|
||||
Present bool
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// CheckSocket stats a milter socket. required distinguishes the two milters:
|
||||
// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
|
||||
// leaving the server, while the journal-milter fails open — mail still goes out,
|
||||
// only the send log stops being written.
|
||||
func CheckSocket(name, path string, required bool) Socket {
|
||||
s := Socket{Name: name, Path: path}
|
||||
if path == "" {
|
||||
s.Status = StatusUnknown
|
||||
s.Detail = "No socket path is configured."
|
||||
return s
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.Status = missingStatus(required)
|
||||
s.Detail = missingDetail(name, required)
|
||||
case fi.Mode()&os.ModeSocket == 0:
|
||||
s.Status = missingStatus(required)
|
||||
s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
|
||||
default:
|
||||
s.Present = true
|
||||
s.Status = StatusOK
|
||||
s.Detail = "Listening."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func missingStatus(required bool) Status {
|
||||
if required {
|
||||
return StatusError
|
||||
}
|
||||
return StatusWarn
|
||||
}
|
||||
|
||||
func missingDetail(name string, required bool) string {
|
||||
if required {
|
||||
return fmt.Sprintf("The %s socket is missing. Postfix rejects mail with a temporary error until it is back.", name)
|
||||
}
|
||||
return fmt.Sprintf("The %s socket is missing. Mail still goes out, but the send log is not being written.", name)
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// Package logtail follows Postfix's mail.log and reconciles the send-log
|
||||
// delivery statuses the journal-milter could not know at receive time
|
||||
// (architecture.md § Persistence). A milter row starts life as "queued";
|
||||
// Postfix only decides sent / deferred / bounced later, per recipient, and
|
||||
// reports it in mail.log. This package parses those lines by queue-id +
|
||||
// recipient and advances the matching rows, and prunes rows past the retention
|
||||
// window.
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// StatusStore is the slice of the store the log-tailer needs: advancing
|
||||
// delivery statuses, finding the rows still waiting for one, pruning the
|
||||
// retention window, and remembering how far into mail.log it has read.
|
||||
// *store.Store satisfies it.
|
||||
type StatusStore interface {
|
||||
UpdateStatus(queueID, recipient, status string) (int64, error)
|
||||
ListQueuedOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
|
||||
DeleteSendLogBefore(cutoff time.Time) (int64, error)
|
||||
LogtailState(path string) (store.LogtailState, bool, error)
|
||||
SaveLogtailState(path string, st store.LogtailState) error
|
||||
}
|
||||
|
||||
// pollInterval is how often the tail loop checks for new bytes / rotation. It
|
||||
// is a var so tests can shorten it.
|
||||
var pollInterval = time.Second
|
||||
|
||||
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
|
||||
// It is a var so tests can answer without a running Postfix.
|
||||
var queueIDs = postfix.QueueIDs
|
||||
|
||||
const (
|
||||
// retentionInterval is how often the retention sweep runs (also once at
|
||||
// startup). The window itself is configurable; the cadence need not be.
|
||||
retentionInterval = 6 * time.Hour
|
||||
// defaultRetentionDays applies when the configured value is unset/invalid
|
||||
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
|
||||
defaultRetentionDays = 90
|
||||
// reconcileInterval is how often the sweep compares stuck rows against the
|
||||
// Postfix queue, and reconcileGrace how long a row is left alone first.
|
||||
// The grace covers the ordinary lag between the milter writing the row and
|
||||
// Postfix logging the result — seconds, generously rounded up — so a
|
||||
// message merely in flight is never touched.
|
||||
reconcileInterval = 5 * time.Minute
|
||||
reconcileGrace = 2 * time.Minute
|
||||
)
|
||||
|
||||
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
|
||||
// and status, e.g.
|
||||
//
|
||||
// postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK)
|
||||
//
|
||||
// The "<queue-id>: to=<addr>, …, status=<word>" shape is specific to the
|
||||
// delivery agents; qmgr/smtpd/cleanup lines do not match.
|
||||
//
|
||||
// The run before status= is lazy on purpose. Postfix appends the remote
|
||||
// server's reply verbatim, so a greedy match would take the *last* status= on
|
||||
// the line — and that one can come from the reply text, which the far end
|
||||
// controls. A bounce whose reply quoted "status=sent" would then be filed as a
|
||||
// success. The real field is always the first one after to=<…>.
|
||||
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*?\bstatus=(\w+)`)
|
||||
|
||||
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
|
||||
// ok is false for lines that are not recognised delivery results.
|
||||
func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
|
||||
m := deliveryRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
switch m[3] {
|
||||
case "sent":
|
||||
status = store.StatusSent
|
||||
case "deferred":
|
||||
status = store.StatusDeferred
|
||||
case "bounced":
|
||||
status = store.StatusBounced
|
||||
case "expired":
|
||||
// Postfix gave up after the queue lifetime; a final failure for us.
|
||||
status = store.StatusBounced
|
||||
default:
|
||||
return "", "", "", false
|
||||
}
|
||||
return m[1], m[2], status, true
|
||||
}
|
||||
|
||||
// Run follows path and updates send-log statuses until ctx is cancelled, while
|
||||
// a background sweep prunes rows older than retentionDays. Reading resumes at
|
||||
// the offset the previous run persisted, so a restart parses the delivery lines
|
||||
// written while the panel was down. It returns nil on a clean shutdown.
|
||||
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
|
||||
go retentionLoop(ctx, st, retentionDays)
|
||||
|
||||
// The reconcile sweep must not run against a backlog the tailer has not
|
||||
// read yet: on a restart the log holds the very lines that resolve the rows
|
||||
// the sweep would otherwise close. follow() closes this once it has read to
|
||||
// end-of-file for the first time.
|
||||
caughtUp := make(chan struct{})
|
||||
go reconcileLoop(ctx, st, caughtUp)
|
||||
|
||||
return follow(ctx, path, &tracker{st: st, path: path}, caughtUp, func(line string) {
|
||||
queueID, recipient, status, ok := parseDelivery(line)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := st.UpdateStatus(queueID, recipient, status); err != nil {
|
||||
log.Printf("log-tailer: update %s/%s -> %s: %v", queueID, recipient, status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileLoop periodically closes send-log rows Postfix has stopped working
|
||||
// on (architecture.md § Log tailer). It starts only once the tailer has caught
|
||||
// up with the log, and then leaves the first sweep a full interval away, so a
|
||||
// restart resolves rows from the log — the accurate source — before the sweep
|
||||
// gets to guess at whatever the log could not explain.
|
||||
func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-caughtUp:
|
||||
}
|
||||
|
||||
t := time.NewTicker(reconcileInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
reconcile(st, time.Now().UTC().Add(-reconcileGrace))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile marks as bounced every row still "queued" from before cutoff whose
|
||||
// message Postfix no longer holds.
|
||||
//
|
||||
// A row reaches this state only when its delivery lines are gone for good — the
|
||||
// log rotated past its fourteen files while the panel was down, or was deleted
|
||||
// — since the log itself now outlives the container. Postfix having dropped the
|
||||
// message means it will never report anything more about it, so the row can
|
||||
// only be closed on an assumption; it is closed as a failure rather than a
|
||||
// success because a delivery the panel cannot evidence must not be shown as
|
||||
// one. Rows whose message is still in the queue, and every row when the queue
|
||||
// cannot be listed at all, are left exactly as they are.
|
||||
func reconcile(st StatusStore, cutoff time.Time) {
|
||||
rows, err := st.ListQueuedOlderThan(cutoff)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: reconcile: list queued rows: %v", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
held, err := queueIDs()
|
||||
if err != nil {
|
||||
// No listing is no information: closing rows now would be a guess made
|
||||
// against nothing.
|
||||
log.Printf("log-tailer: reconcile: read postfix queue: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var closed int
|
||||
for _, row := range rows {
|
||||
if _, still := held[row.QueueID]; still {
|
||||
continue
|
||||
}
|
||||
if _, err := st.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
|
||||
log.Printf("log-tailer: reconcile: close %s/%s: %v", row.QueueID, row.To, err)
|
||||
continue
|
||||
}
|
||||
closed++
|
||||
}
|
||||
if closed > 0 {
|
||||
log.Printf("log-tailer: reconcile: closed %d row(s) Postfix no longer holds and never reported", closed)
|
||||
}
|
||||
}
|
||||
|
||||
// retentionLoop prunes expired send-log rows immediately and then periodically.
|
||||
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
retentionDays = defaultRetentionDays
|
||||
}
|
||||
prune := func() {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
|
||||
n, err := st.DeleteSendLogBefore(cutoff)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: retention prune: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("log-tailer: pruned %d send-log rows older than %d days", n, retentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
prune()
|
||||
t := time.NewTicker(retentionInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
prune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TailLines returns up to n of the most recent lines from path, for the
|
||||
// panel's mail.log monitoring view (architecture.md § Panel HTTP surface). It
|
||||
// is a one-shot, point-in-time read on request — unrelated to the background
|
||||
// follow loop above — that reads backwards in chunks so it stays cheap against
|
||||
// a multi-megabyte log rather than reading the whole file every poll.
|
||||
func TailLines(path string, n int) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
const chunkSize = 8192
|
||||
var (
|
||||
buf []byte
|
||||
offset = info.Size()
|
||||
)
|
||||
for offset > 0 && bytes.Count(buf, []byte("\n")) <= n {
|
||||
size := int64(chunkSize)
|
||||
if size > offset {
|
||||
size = offset
|
||||
}
|
||||
offset -= size
|
||||
chunk := make([]byte, size)
|
||||
if _, err := f.ReadAt(chunk, offset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(chunk, buf...)
|
||||
}
|
||||
|
||||
text := strings.TrimRight(string(buf), "\n")
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// queueScanBytes bounds how far back QueueLines reads. A message's own lines
|
||||
// are a handful, but they are scattered through everything else the mail path
|
||||
// logged around them, so finding them means reading rather than seeking. The
|
||||
// budget is what keeps that read bounded on a log that has grown for a day:
|
||||
// beyond it the answer is "not in the part of the log still on disk", which is
|
||||
// the same answer rotation gives and is reported the same way. It is a var so
|
||||
// tests can shrink it, the way pollInterval above is.
|
||||
var queueScanBytes int64 = 4 << 20
|
||||
|
||||
// QueueLines returns the mail.log lines Postfix wrote about one queue id,
|
||||
// oldest first and at most n of them, for a single delivery's page
|
||||
// (architecture.md § Panel HTTP surface). It reads the tail of the log the way
|
||||
// TailLines does — one-shot, on request, unrelated to the follow loop above —
|
||||
// but keeps only the lines belonging to this message instead of the last n of
|
||||
// everything.
|
||||
//
|
||||
// A message older than the tail scanned, or older than the current log file,
|
||||
// comes back empty rather than as an error: send-log rows outlive mail.log
|
||||
// (retention is ninety days by default, rotation keeps fourteen files), so a
|
||||
// row with nothing left to show for it is expected.
|
||||
func QueueLines(path, queueID string, n int) ([]string, error) {
|
||||
// No queue-id, no lines: a message the milter refused was never queued, so
|
||||
// there is nothing to match on and every line would be someone else's.
|
||||
if queueID == "" || n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Forward from the start of the budget rather than backwards in chunks:
|
||||
// the lines are wanted oldest first, and the whole budget is read either
|
||||
// way, so reading it in order costs nothing and keeps them in order.
|
||||
start := info.Size() - queueScanBytes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
// A Postfix line carrying a long remote reply can pass the scanner's default
|
||||
// 64KB token; without a bigger ceiling that one line would end the scan and
|
||||
// silently truncate the answer.
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
// Reading into the middle of the file lands mid-line; that fragment is
|
||||
// dropped rather than reported as a line of its own.
|
||||
if start > 0 && sc.Scan() {
|
||||
_ = sc.Text()
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !mentionsQueueID(line, queueID) {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
// Keep the latest n rather than stopping at the first n: what a message
|
||||
// did last is what its page is opened for.
|
||||
if len(lines) > n {
|
||||
lines = lines[1:]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// mentionsQueueID reports whether a mail.log line is about queueID. Postfix
|
||||
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…"),
|
||||
// and the match is anchored on the character before it so a shorter id is not
|
||||
// found inside a longer one — queue ids are hexadecimal, and one being the tail
|
||||
// of another is ordinary, not unlikely.
|
||||
func mentionsQueueID(line, queueID string) bool {
|
||||
needle := queueID + ":"
|
||||
for i := 0; i <= len(line)-len(needle); {
|
||||
j := strings.Index(line[i:], needle)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
j += i
|
||||
if j == 0 || !isQueueIDByte(line[j-1]) {
|
||||
return true
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isQueueIDByte(b byte) bool {
|
||||
return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
|
||||
}
|
||||
|
||||
// Timestamps at the head of a mail.log line, in the two formats postlogd
|
||||
// writes (maillog_file in build/postfix-config.sh).
|
||||
//
|
||||
// syslogStampRe is the one that matches in practice today: the format is
|
||||
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
|
||||
// image is built on Debian's 3.7 — where the parameter does not exist and the
|
||||
// only format is syslog's traditional one. It carries no year and no zone, so
|
||||
// the stamp shown is a wall clock and nothing more, which is all this column
|
||||
// claims to be.
|
||||
//
|
||||
// isoStampRe is for the RFC 3339 format that same parameter selects once the
|
||||
// base image carries a Postfix new enough to offer it. Matching it first costs
|
||||
// one failed anchor per line and means the upgrade needs no change here.
|
||||
var (
|
||||
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
|
||||
syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
|
||||
)
|
||||
|
||||
// SplitTimestamp separates the timestamp at the head of a mail.log line from
|
||||
// the rest of it, so a page can put the two in their own columns. The stamp
|
||||
// comes back without its fractional seconds and zone offset — five decimal
|
||||
// places of microsecond are the widest part of the column and the least worth
|
||||
// reading — but is otherwise the log's own wall clock, not converted: what is
|
||||
// on the page is what is in the file.
|
||||
//
|
||||
// A line whose head is not a timestamp this recognises comes back whole, as
|
||||
// rest, with an empty stamp. Nothing is ever dropped: the point of showing the
|
||||
// log is that it says what it says.
|
||||
func SplitTimestamp(line string) (stamp, rest string) {
|
||||
if m := isoStampRe.FindStringSubmatch(line); m != nil {
|
||||
return m[1] + " " + m[2], strings.TrimSpace(line[len(m[0]):])
|
||||
}
|
||||
if m := syslogStampRe.FindStringSubmatch(line); m != nil {
|
||||
return m[1], strings.TrimSpace(line[len(m[0]):])
|
||||
}
|
||||
return "", line
|
||||
}
|
||||
|
||||
// follow tails path line by line, calling handle for each complete line, until
|
||||
// ctx is cancelled. Where it starts is tr's decision (a persisted offset, the
|
||||
// start of a file that changed while the panel was down, or end-of-file on a
|
||||
// first ever run); it reopens the file when it is rotated (inode change from
|
||||
// logrotate's create, or truncation from copytruncate) so nothing is missed.
|
||||
//
|
||||
// caughtUp is closed after the first read that reaches end-of-file, which is
|
||||
// the point where every line the panel missed while it was down has been
|
||||
// handled.
|
||||
func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{}, handle func(string)) error {
|
||||
var (
|
||||
f *os.File
|
||||
r *bufio.Reader
|
||||
info os.FileInfo
|
||||
pending string
|
||||
)
|
||||
openAt := func(offset int64, whence int) error {
|
||||
nf, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := nf.Seek(offset, whence); err != nil {
|
||||
nf.Close()
|
||||
return err
|
||||
}
|
||||
ni, err := nf.Stat()
|
||||
if err != nil {
|
||||
nf.Close()
|
||||
return err
|
||||
}
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
f, r, info, pending = nf, bufio.NewReader(nf), ni, ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// The container may start before Postfix has created mail.log; wait for it.
|
||||
for {
|
||||
if err := openAt(0, io.SeekStart); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
if _, err := f.Seek(tr.resume(f), io.SeekStart); err != nil {
|
||||
log.Printf("log-tailer: seek %s: %v", path, err)
|
||||
}
|
||||
r.Reset(f) // the reader buffered from the pre-seek position
|
||||
defer func() {
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
drain := func() {
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
pending += line // hold the partial line until it completes
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: read %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
full := pending + line
|
||||
pending = ""
|
||||
handle(strings.TrimRight(full, "\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// read returns how many bytes of the open file have actually been consumed:
|
||||
// the descriptor position less the partial line bufio handed back at EOF,
|
||||
// which is re-read (and completed) on the next drain or the next start.
|
||||
read := func() int64 {
|
||||
pos, _ := f.Seek(0, io.SeekCurrent)
|
||||
return pos - int64(len(pending))
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
tr.record(f, read(), true) // shutdown: the next start resumes here
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
drain()
|
||||
once.Do(func() { close(caughtUp) })
|
||||
ni, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // file briefly gone mid-rotation; try again next tick
|
||||
}
|
||||
pos, _ := f.Seek(0, io.SeekCurrent)
|
||||
if !os.SameFile(info, ni) || ni.Size() < pos {
|
||||
// Rotated away or truncated: the old (renamed) inode may have
|
||||
// gained lines between the drain() above and this check, since
|
||||
// Postfix keeps writing to it until it reloads. Drain it once
|
||||
// more before switching so nothing in that gap is lost.
|
||||
drain()
|
||||
if err := openAt(0, io.SeekStart); err != nil {
|
||||
log.Printf("log-tailer: reopen %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
tr.adopt(f)
|
||||
continue
|
||||
}
|
||||
tr.record(f, read(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func TestParseDelivery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
wantOK bool
|
||||
queueID, recipient, status string
|
||||
}{
|
||||
{
|
||||
name: "sent",
|
||||
line: "2026-07-11T11:55:34 host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, delay=0.5, dsn=2.0.0, status=sent (250 OK)",
|
||||
wantOK: true,
|
||||
queueID: "41E862C00D9E",
|
||||
recipient: "a@example.net",
|
||||
status: store.StatusSent,
|
||||
},
|
||||
{
|
||||
name: "deferred",
|
||||
line: "host postfix/smtp[26]: 5900C2C00D9E: to=<y@example.net>, relay=none, delay=30, dsn=4.4.1, status=deferred (connect timed out)",
|
||||
wantOK: true,
|
||||
queueID: "5900C2C00D9E",
|
||||
recipient: "y@example.net",
|
||||
status: store.StatusDeferred,
|
||||
},
|
||||
{
|
||||
name: "bounced",
|
||||
line: "host postfix/smtp[26]: ABC: to=<no@example.net>, relay=…, dsn=5.1.1, status=bounced (user unknown)",
|
||||
wantOK: true,
|
||||
queueID: "ABC",
|
||||
recipient: "no@example.net",
|
||||
status: store.StatusBounced,
|
||||
},
|
||||
{
|
||||
name: "expired maps to bounced",
|
||||
line: "host postfix/smtp[26]: DEF: to=<slow@example.net>, relay=none, status=expired (delivery temporarily suspended)",
|
||||
wantOK: true,
|
||||
queueID: "DEF",
|
||||
recipient: "slow@example.net",
|
||||
status: store.StatusBounced,
|
||||
},
|
||||
{
|
||||
name: "qmgr from-line ignored",
|
||||
line: "host postfix/qmgr[10]: 41E862C00D9E: from=<noreply@example.com>, size=500, nrcpt=1 (queue active)",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "smtpd client-line ignored",
|
||||
line: "host postfix/smtpd[10]: 41E862C00D9E: client=unknown[203.0.113.7]",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// The remote server's reply is quoted verbatim at the end of the
|
||||
// line and is entirely attacker-influenced text. A "status=" that
|
||||
// appears in there must not win over the real field, or a bounce
|
||||
// would be filed as a success.
|
||||
name: "status= quoted in the remote reply does not win",
|
||||
line: "host postfix/smtp[26]: 9F1A2C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=5.1.1, status=bounced (host mx.example.net said: 550 5.1.1 unknown status=sent (in reply to RCPT TO command))",
|
||||
wantOK: true,
|
||||
queueID: "9F1A2C00D9E",
|
||||
recipient: "a@example.net",
|
||||
status: store.StatusBounced,
|
||||
},
|
||||
{
|
||||
// Postfix logs the null sender's own delivery (double bounce) with
|
||||
// an empty recipient. It parses, and the empty recipient simply
|
||||
// matches no send-log row — the panel only ever records mail it
|
||||
// accepted from an authenticated client.
|
||||
name: "null recipient parses with an empty address",
|
||||
line: "host postfix/smtp[26]: A1B2C3: to=<>, relay=none, delay=0.1, dsn=2.0.0, status=sent (250 OK)",
|
||||
wantOK: true,
|
||||
queueID: "A1B2C3",
|
||||
recipient: "",
|
||||
status: store.StatusSent,
|
||||
},
|
||||
{
|
||||
// An alias/virtual expansion carries orig_to= as well; the address
|
||||
// the message was actually delivered to is the one in to=.
|
||||
name: "orig_to is ignored in favour of to",
|
||||
line: "host postfix/lmtp[26]: 4Xk9tS1abcz: to=<real@example.net>, orig_to=<alias@example.net>, relay=x, dsn=2.0.0, status=sent (ok)",
|
||||
wantOK: true,
|
||||
queueID: "4Xk9tS1abcz",
|
||||
recipient: "real@example.net",
|
||||
status: store.StatusSent,
|
||||
},
|
||||
{
|
||||
// Postfix's own delivery agents write these two, but neither is a
|
||||
// final result we model: "deliverable" comes from address
|
||||
// verification probes, and anything unrecognised is dropped rather
|
||||
// than guessed at, leaving the row in its previous state.
|
||||
name: "unknown status word is not a delivery result",
|
||||
line: "host postfix/smtp[26]: BEEF01: to=<a@example.net>, relay=x, status=deliverable (ok)",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "status matching is case-sensitive, as Postfix writes it",
|
||||
line: "host postfix/smtp[26]: BEEF02: to=<a@example.net>, relay=x, dsn=4.0.0, status=Deferred (connect timed out)",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "cleanup message-id line ignored",
|
||||
line: "host postfix/cleanup[12]: BEEF03: message-id=<x@example.com>",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, r, s, ok := parseDelivery(c.line)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, c.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if q != c.queueID || r != c.recipient || s != c.status {
|
||||
t.Fatalf("got (%q,%q,%q), want (%q,%q,%q)", q, r, s, c.queueID, c.recipient, c.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// captureStore records UpdateStatus calls for the follow integration test and
|
||||
// keeps the persisted read offset in memory, so a "restart" in a test is a
|
||||
// second Run against the same captureStore.
|
||||
type captureStore struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
|
||||
state store.LogtailState
|
||||
haveState bool
|
||||
stateErr error
|
||||
|
||||
// queued is what ListQueuedOlderThan returns, for the reconcile sweep.
|
||||
queued []store.QueuedDelivery
|
||||
queuedErr error
|
||||
}
|
||||
|
||||
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls = append(c.calls, queueID+"|"+recipient+"|"+status)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
func (c *captureStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.queued, c.queuedErr
|
||||
}
|
||||
|
||||
func (c *captureStore) LogtailState(string) (store.LogtailState, bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.stateErr != nil {
|
||||
return store.LogtailState{}, false, c.stateErr
|
||||
}
|
||||
return c.state, c.haveState, nil
|
||||
}
|
||||
|
||||
func (c *captureStore) SaveLogtailState(_ string, st store.LogtailState) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.stateErr != nil {
|
||||
return c.stateErr
|
||||
}
|
||||
c.state, c.haveState = st, true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureStore) snapshot() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.calls...)
|
||||
}
|
||||
|
||||
func (c *captureStore) reset() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls = nil
|
||||
}
|
||||
|
||||
// TestFollowTailsAndRotates writes delivery lines to a log file, then rotates
|
||||
// it (rename + fresh create, as logrotate does) and writes more, asserting the
|
||||
// tailer picks up lines from both the original and rotated file.
|
||||
func TestFollowTailsAndRotates(t *testing.T) {
|
||||
old := pollInterval
|
||||
pollInterval = 10 * time.Millisecond
|
||||
t.Cleanup(func() { pollInterval = old })
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mail.log")
|
||||
if err := os.WriteFile(path, []byte("preexisting line, ignored on start\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed log: %v", err)
|
||||
}
|
||||
|
||||
cs := &captureStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Run(ctx, path, cs, 90) }()
|
||||
|
||||
// Give follow() time to open at EOF (it seeks to end immediately on start,
|
||||
// so the seed line above is ignored), then append a delivery line.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
appendLine(t, path, "host postfix/smtp[1]: Q1: to=<a@example.net>, dsn=2.0.0, status=sent (ok)")
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "Q1|a@example.net|sent") })
|
||||
|
||||
// Rotate: move the current file aside and create a fresh one (logrotate
|
||||
// "create"), then append to the new file.
|
||||
if err := os.Rename(path, path+".1"); err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("recreate: %v", err)
|
||||
}
|
||||
appendLine(t, path, "host postfix/smtp[1]: Q2: to=<b@example.net>, dsn=5.1.1, status=bounced (nope)")
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "Q2|b@example.net|bounced") })
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFollowResumesAfterRestart covers the persisted read offset: a restart
|
||||
// must parse the delivery lines written while the tailer was down (rows that
|
||||
// would otherwise stay "queued" forever), without re-parsing what it already
|
||||
// read, and must fall back to reading the whole file when the log was rotated
|
||||
// or recreated in the meantime.
|
||||
func TestFollowResumesAfterRestart(t *testing.T) {
|
||||
old := pollInterval
|
||||
pollInterval = 10 * time.Millisecond
|
||||
t.Cleanup(func() { pollInterval = old })
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mail.log")
|
||||
// A head longer than fingerprintSize, so the file stays identifiable across
|
||||
// the restart; the lines themselves predate the first start and are ignored.
|
||||
seed := strings.Repeat("host postfix/qmgr[1]: seed line, not a delivery\n", 20)
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed log: %v", err)
|
||||
}
|
||||
|
||||
cs := &captureStore{}
|
||||
stop := startRun(t, path, cs)
|
||||
appendLine(t, path, "host postfix/smtp[1]: Q1: to=<a@example.net>, dsn=2.0.0, status=sent (ok)")
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "Q1|a@example.net|sent") })
|
||||
stop() // persists the offset past Q1
|
||||
|
||||
// Down: Postfix keeps delivering.
|
||||
appendLine(t, path, "host postfix/smtp[1]: Q2: to=<b@example.net>, dsn=2.0.0, status=sent (ok)")
|
||||
|
||||
cs.reset()
|
||||
stop = startRun(t, path, cs)
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "Q2|b@example.net|sent") })
|
||||
if contains(cs.snapshot(), "Q1|a@example.net|sent") {
|
||||
t.Fatal("resumed run re-parsed Q1: offset was not honoured")
|
||||
}
|
||||
stop()
|
||||
|
||||
// Down again, and this time the log is replaced (logrotate + fresh create).
|
||||
// The stored offset belongs to a file that no longer exists, so the new one
|
||||
// must be read from the start.
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("host postfix/qmgr[1]: fresh log after rotation\n", 20)+
|
||||
"host postfix/smtp[1]: Q3: to=<c@example.net>, dsn=5.1.1, status=bounced (nope)\n"), 0o644); err != nil {
|
||||
t.Fatalf("recreate log: %v", err)
|
||||
}
|
||||
|
||||
cs.reset()
|
||||
stop = startRun(t, path, cs)
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "Q3|c@example.net|bounced") })
|
||||
stop()
|
||||
}
|
||||
|
||||
// startRun launches the tailer and returns a function that cancels it and waits
|
||||
// for a clean return, the way a panel restart bookends a run.
|
||||
func startRun(t *testing.T, path string, cs *captureStore) func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Run(ctx, path, cs, 90) }()
|
||||
// follow() opens and seeks on start; give it a moment before the caller
|
||||
// appends, so the append is not raced by the initial open.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return func() {
|
||||
t.Helper()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendLine(t *testing.T, path, line string) {
|
||||
t.Helper()
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("open for append: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(line + "\n"); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
// stubQueue makes the reconcile sweep answer from a fixed list of queue ids
|
||||
// instead of a running Postfix, or fail if err is non-nil.
|
||||
func stubQueue(t *testing.T, err error, ids ...string) *int {
|
||||
t.Helper()
|
||||
calls := 0
|
||||
old := queueIDs
|
||||
queueIDs = func() (map[string]struct{}, error) {
|
||||
calls++
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
t.Cleanup(func() { queueIDs = old })
|
||||
return &calls
|
||||
}
|
||||
|
||||
// A row stays "queued" forever when its delivery lines are gone for good. The
|
||||
// queue is what settles it: a message Postfix still holds is simply in flight
|
||||
// and must be left alone, while one it no longer holds will never be reported
|
||||
// on and is closed.
|
||||
func TestReconcileClosesOnlyWhatPostfixNoLongerHolds(t *testing.T) {
|
||||
stubQueue(t, nil, "STILLQ")
|
||||
|
||||
cs := &captureStore{queued: []store.QueuedDelivery{
|
||||
{QueueID: "STILLQ", To: "inflight@example.net"},
|
||||
{QueueID: "GONEQ", To: "lost@example.net"},
|
||||
}}
|
||||
reconcile(cs, time.Now().UTC())
|
||||
|
||||
got := cs.snapshot()
|
||||
if len(got) != 1 || got[0] != "GONEQ|lost@example.net|"+store.StatusBounced {
|
||||
t.Fatalf("got %v, want only the message Postfix dropped closed as bounced", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A queue that cannot be listed says nothing about any message — treating the
|
||||
// failure as an empty queue would close every stale row at once.
|
||||
func TestReconcileLeavesRowsAloneWhenTheQueueCannotBeRead(t *testing.T) {
|
||||
stubQueue(t, errors.New("postqueue: Permission denied"))
|
||||
|
||||
cs := &captureStore{queued: []store.QueuedDelivery{
|
||||
{QueueID: "GONEQ", To: "lost@example.net"},
|
||||
}}
|
||||
reconcile(cs, time.Now().UTC())
|
||||
|
||||
if got := cs.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want no row touched", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing stale to explain there is no reason to shell out to postqueue at
|
||||
// all — which is the normal state of a relay that is keeping up.
|
||||
func TestReconcileSkipsTheQueueWhenNoRowIsStuck(t *testing.T) {
|
||||
calls := stubQueue(t, nil)
|
||||
|
||||
reconcile(&captureStore{}, time.Now().UTC())
|
||||
|
||||
if *calls != 0 {
|
||||
t.Fatalf("queue listed %d time(s), want none", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery's page shows what Postfix wrote about that one message, so the
|
||||
// read has to pick its queue-id's lines out of everything else the mail path
|
||||
// logged around them — and only its own: queue ids are hexadecimal runs, and a
|
||||
// shorter one is regularly the tail of a longer one.
|
||||
func TestQueueLinesPicksOutOneMessage(t *testing.T) {
|
||||
path := writeLog(t,
|
||||
"host postfix/smtpd[20]: 41E862C00D9E: client=mail.example.com[203.0.113.4]",
|
||||
"host postfix/qmgr[10]: 5900C2C00D9E: from=<other@example.com>, size=500, nrcpt=1 (queue active)",
|
||||
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
|
||||
// Same run of characters, longer id: not this message.
|
||||
"host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)",
|
||||
"host opendkim[30]: 41E862C00D9E: DKIM-Signature field added (s=mail d=example.com)",
|
||||
"host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
|
||||
)
|
||||
|
||||
lines, err := QueueLines(path, "41E862C00D9E", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d lines, want 4:\n%s", len(lines), strings.Join(lines, "\n"))
|
||||
}
|
||||
// Oldest first: the page reads the message's history downwards.
|
||||
if !strings.Contains(lines[0], "client=") || !strings.Contains(lines[3], "status=sent") {
|
||||
t.Errorf("lines are not in the order they were logged:\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "FF41E862C00D9E") || strings.Contains(line, "5900C2C00D9E") {
|
||||
t.Errorf("another message's line came back: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A message the milter refused has no queue id, so there is nothing to match
|
||||
// on — every line in the log would be someone else's.
|
||||
func TestQueueLinesWithoutAQueueIDMatchesNothing(t *testing.T) {
|
||||
path := writeLog(t, "host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, status=sent (250 OK)")
|
||||
|
||||
lines, err := QueueLines(path, "", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if lines != nil {
|
||||
t.Errorf("got %v, want no lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// The cap keeps the newest lines, not the first ones: what a message did last
|
||||
// is what its page is opened for.
|
||||
func TestQueueLinesCapKeepsTheLatest(t *testing.T) {
|
||||
var log []string
|
||||
for i := 0; i < 10; i++ {
|
||||
log = append(log, "host postfix/smtp[26]: ABC123: attempt "+itoa(i))
|
||||
}
|
||||
path := writeLog(t, log...)
|
||||
|
||||
lines, err := QueueLines(path, "ABC123", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 3 || !strings.HasSuffix(lines[0], "attempt 7") || !strings.HasSuffix(lines[2], "attempt 9") {
|
||||
t.Errorf("cap did not keep the last three:\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Send-log rows outlive mail.log — retention is ninety days and rotation keeps
|
||||
// fourteen files — so a message whose lines are gone, or a log that is between
|
||||
// rotations and absent altogether, is an empty answer for the page to explain,
|
||||
// not an error for it to report.
|
||||
func TestQueueLinesOnAMessageWithNoLinesLeft(t *testing.T) {
|
||||
path := writeLog(t, "host postfix/smtp[26]: 5900C2C00D9E: to=<z@example.net>, status=sent (250 OK)")
|
||||
|
||||
lines, err := QueueLines(path, "41E862C00D9E", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if lines != nil {
|
||||
t.Errorf("got %v, want no lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLog creates a mail.log holding the given lines and returns its path.
|
||||
func writeLog(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
body := ""
|
||||
for _, line := range lines {
|
||||
body += line + "\n"
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write log: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// The read is bounded, so on a log that has grown all day it starts in the
|
||||
// middle of a line. That fragment is not a line and must not come back as one,
|
||||
// and anything before the budget is out of reach — which the page reports the
|
||||
// same way as a message whose lines have rotated away.
|
||||
func TestQueueLinesReadsABoundedTail(t *testing.T) {
|
||||
old := queueScanBytes
|
||||
queueScanBytes = 120
|
||||
t.Cleanup(func() { queueScanBytes = old })
|
||||
|
||||
path := writeLog(t,
|
||||
"host postfix/smtp[26]: ABC123: too far back to reach, padded out past the budget with this run of filler text",
|
||||
"host postfix/smtp[26]: ABC123: within the budget",
|
||||
)
|
||||
|
||||
lines, err := QueueLines(path, "ABC123", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 1 || !strings.HasSuffix(lines[0], "within the budget") {
|
||||
t.Errorf("got %d lines, want only the one inside the budget:\n%s", len(lines), strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery page shows a message's lines as a table of when and what, so the
|
||||
// timestamp has to come off the head of the line — in either of the two formats
|
||||
// a mail log arrives in — and nothing may be lost doing it.
|
||||
func TestSplitTimestamp(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
stamp, rest string
|
||||
}{
|
||||
{
|
||||
name: "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up",
|
||||
line: "2026-08-03T05:15:52.219218+00:00 mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
},
|
||||
{
|
||||
name: "no fractional seconds, zone as Z",
|
||||
line: "2026-08-03T05:15:52Z mail postfix/qmgr[10]: 4A1B2C3D: removed",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail postfix/qmgr[10]: 4A1B2C3D: removed",
|
||||
},
|
||||
{
|
||||
name: "no zone at all",
|
||||
line: "2026-08-03T05:15:52 mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
|
||||
},
|
||||
{
|
||||
name: "syslog's traditional format, space-padded day",
|
||||
line: "Aug 3 05:15:52 mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
stamp: "Aug 3 05:15:52",
|
||||
rest: "mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
},
|
||||
{
|
||||
// Copied off the live relay (Postfix 3.7, which has no
|
||||
// maillog_file_format), so this is the shape the panel actually
|
||||
// meets: zero-padded day, and the host is the container's name.
|
||||
name: "syslog's traditional format as the live relay writes it",
|
||||
line: "Aug 08 07:26:41 selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
stamp: "Aug 08 07:26:41",
|
||||
rest: "selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
},
|
||||
{
|
||||
name: "unrecognised head keeps the whole line",
|
||||
line: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
stamp: "",
|
||||
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
},
|
||||
{
|
||||
// A date-like run that is not the head of the line is not a stamp.
|
||||
name: "date inside the text is left alone",
|
||||
line: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
|
||||
stamp: "",
|
||||
rest: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
stamp, rest := SplitTimestamp(c.line)
|
||||
if stamp != c.stamp || rest != c.rest {
|
||||
t.Errorf("SplitTimestamp(%q) = (%q, %q), want (%q, %q)", c.line, stamp, rest, c.stamp, c.rest)
|
||||
}
|
||||
// Whatever the split, the line's own text survives it whole.
|
||||
if !strings.Contains(c.line, rest) {
|
||||
t.Errorf("the text column is not part of the line it came from: %q", rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// fingerprintSize is how many bytes from the head of the log identify it.
|
||||
// Postfix writes a timestamped line per event, so the first 512 bytes are
|
||||
// effectively unique per log generation — enough to tell "the file we were
|
||||
// reading" from "a fresh one created by logrotate while we were down",
|
||||
// which os.SameFile cannot answer across a restart.
|
||||
fingerprintSize = 512
|
||||
// persistInterval throttles the offset write. Losing up to this much
|
||||
// progress on a crash only means re-parsing a few lines (UpdateStatus is
|
||||
// idempotent), which is much cheaper than a database write per poll tick.
|
||||
persistInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// tracker persists the tailer's read position so a restart resumes where the
|
||||
// previous run stopped instead of jumping to end-of-file — the "send-log rows
|
||||
// stay queued forever" gap (architecture.md § Log tailer). It is used from the
|
||||
// follow loop only, so it needs no locking.
|
||||
type tracker struct {
|
||||
st StatusStore
|
||||
path string
|
||||
|
||||
fp string // fingerprint of the file currently open ("" if too short)
|
||||
saved int64 // last offset written to the store
|
||||
lastSave time.Time
|
||||
}
|
||||
|
||||
// resume returns the byte offset the tailer should start reading f at, having
|
||||
// recorded f's fingerprint for later saves.
|
||||
//
|
||||
// The rules, in order: no stored state at all (first ever start) means start at
|
||||
// the end, so installing the panel does not replay a pre-existing log; a stored
|
||||
// state whose fingerprint still matches means continue from it, parsing the
|
||||
// tail written while the panel was down; anything else means the file is not
|
||||
// the one the offset referred to (rotated, recreated or truncated in the
|
||||
// meantime), so read it from the start. Re-parsing lines already seen is
|
||||
// harmless: UpdateStatus writes the same status onto the same row.
|
||||
func (t *tracker) resume(f *os.File) int64 {
|
||||
size, err := fileSize(f)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: stat %s: %v (reading from the start)", t.path, err)
|
||||
return 0
|
||||
}
|
||||
t.fp = fingerprintOf(f)
|
||||
|
||||
prev, ok, err := t.st.LogtailState(t.path)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: read stored offset: %v (starting at end)", err)
|
||||
return size
|
||||
}
|
||||
switch {
|
||||
case !ok:
|
||||
t.saved = size
|
||||
return size
|
||||
case prev.Fingerprint != "" && prev.Fingerprint == t.fp && prev.Offset <= size:
|
||||
t.saved = prev.Offset
|
||||
if prev.Offset < size {
|
||||
log.Printf("log-tailer: resuming %s at offset %d (%d bytes to catch up)",
|
||||
t.path, prev.Offset, size-prev.Offset)
|
||||
}
|
||||
return prev.Offset
|
||||
default:
|
||||
log.Printf("log-tailer: %s changed while the panel was down; reading from the start", t.path)
|
||||
t.saved = 0
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// adopt re-fingerprints after the follow loop switched to a rotated-in file and
|
||||
// persists the fresh start immediately, so a restart right after a rotation
|
||||
// does not resume at the old file's offset.
|
||||
func (t *tracker) adopt(f *os.File) {
|
||||
t.fp = fingerprintOf(f)
|
||||
t.saved = -1 // force the write below even if the old offset happened to be 0
|
||||
t.record(f, 0, true)
|
||||
}
|
||||
|
||||
// record persists offset, at most once per persistInterval unless force is set
|
||||
// (rotation and shutdown, where the write must not be skipped).
|
||||
func (t *tracker) record(f *os.File, offset int64, force bool) {
|
||||
if offset == t.saved {
|
||||
return
|
||||
}
|
||||
if !force && time.Since(t.lastSave) < persistInterval {
|
||||
return
|
||||
}
|
||||
if t.fp == "" {
|
||||
// The log was shorter than a fingerprint when we opened it; now that it
|
||||
// has grown, an identifiable one may be available.
|
||||
t.fp = fingerprintOf(f)
|
||||
}
|
||||
if err := t.st.SaveLogtailState(t.path, store.LogtailState{Fingerprint: t.fp, Offset: offset}); err != nil {
|
||||
log.Printf("log-tailer: save offset: %v", err)
|
||||
return
|
||||
}
|
||||
t.saved = offset
|
||||
t.lastSave = time.Now()
|
||||
}
|
||||
|
||||
// fingerprintOf hashes the head of the file. It returns "" for a file too short
|
||||
// to identify — the head would still change as Postfix appends, so such a
|
||||
// fingerprint could not be compared meaningfully on the next start.
|
||||
func fingerprintOf(f *os.File) string {
|
||||
buf := make([]byte, fingerprintSize)
|
||||
n, err := f.ReadAt(buf, 0)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Printf("log-tailer: fingerprint read: %v", err)
|
||||
return ""
|
||||
}
|
||||
if n < fingerprintSize {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(buf)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func fileSize(f *os.File) (int64, error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package mailhdr turns raw mail header values into display text. It sits
|
||||
// between the journal-milter, which reads headers off the wire, and the panel,
|
||||
// which shows them: both need the same answer for the same header, and the
|
||||
// panel needs it for rows the milter wrote before this decoding existed.
|
||||
package mailhdr
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SubjectMaxRunes caps what the journal keeps of a subject. A Subject header
|
||||
// may legally run to hundreds of characters; the log only needs enough to
|
||||
// recognise the message, and the panel shows one row per recipient.
|
||||
const SubjectMaxRunes = 200
|
||||
|
||||
// DecodeSubject turns a raw Subject header into display text. Anything
|
||||
// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the
|
||||
// panel would otherwise show verbatim: unreadable, and — being one unbreakable
|
||||
// run — wide enough to push the send-log table out of its card. Go's decoder
|
||||
// covers the UTF-8 and ASCII charsets senders use in practice; for anything
|
||||
// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no
|
||||
// worse than not decoding at all. Truncation is applied after decoding so the
|
||||
// cap counts characters of the subject, not bytes of its encoding.
|
||||
//
|
||||
// It is idempotent: already-decoded text contains no encoded-words, so a second
|
||||
// pass returns it unchanged. That is what lets the panel decode on the way out
|
||||
// as well as the milter on the way in.
|
||||
func DecodeSubject(v string) string {
|
||||
if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil {
|
||||
v = dec
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if r := []rune(v); len(r) > SubjectMaxRunes {
|
||||
v = string(r[:SubjectMaxRunes]) + "…"
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package mailhdr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeSubject(t *testing.T) {
|
||||
long := strings.Repeat("я", SubjectMaxRunes+10)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"plain", "Hello there", "Hello there"},
|
||||
{"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"},
|
||||
{"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"},
|
||||
// No decoder for the legacy single-byte charsets: keep the header as
|
||||
// sent rather than losing the subject entirely.
|
||||
{"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="},
|
||||
{"too long", long, strings.Repeat("я", SubjectMaxRunes) + "…"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DecodeSubject(tc.raw); got != tc.want {
|
||||
t.Fatalf("DecodeSubject(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The panel decodes on the way out as well as the milter on the way in, so
|
||||
// every already-decoded row in the send log passes through a second time.
|
||||
func TestDecodeSubjectIsIdempotent(t *testing.T) {
|
||||
for _, s := range []string{"Проверка", "Hello there", "", "=?windows-1251?B?z/Do4uXy?="} {
|
||||
if got := DecodeSubject(DecodeSubject(s)); got != DecodeSubject(s) {
|
||||
t.Fatalf("second pass over %q changed it to %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package milter
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// reservationTTL bounds how long a message may stay reserved. A reservation is
|
||||
// released at end-of-message or on ABORT, but a client that simply drops the
|
||||
// connection after MAIL FROM produces neither callback (go-milter has no
|
||||
// connection-close hook), and a reservation that never expired would count
|
||||
// against the limit forever — a fail-closed drift this milter must not have.
|
||||
// The TTL is generously longer than any realistic DATA transfer, so a message
|
||||
// still being received is never dropped from the count.
|
||||
const reservationTTL = 10 * time.Minute
|
||||
|
||||
// reservation is one message that passed the level-2 check and has not been
|
||||
// written to the send log yet.
|
||||
type reservation struct {
|
||||
key string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// inflight counts messages that are between the limit check (MAIL FROM) and the
|
||||
// send-log insert (end-of-message). The stored count alone cannot see them, so
|
||||
// without this several concurrent SMTP sessions each read the same pre-insert
|
||||
// count, each conclude they are under the ceiling, and the limit is overshot by
|
||||
// however many were in flight. Counting reservations closes that window without
|
||||
// writing placeholder rows the operator would see in the UI.
|
||||
//
|
||||
// One instance is shared by every session of the process, hence the mutex.
|
||||
// Methods tolerate a nil receiver so a session built without one (tests) simply
|
||||
// behaves as it did before.
|
||||
type inflight struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[*reservation]struct{}
|
||||
}
|
||||
|
||||
// count returns how many reservations for key were taken within the limit's
|
||||
// window (at or after since), pruning any that outlived reservationTTL.
|
||||
func (f *inflight) count(key string, since time.Time) int64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
set := f.m[key]
|
||||
cutoff := time.Now().Add(-reservationTTL)
|
||||
var n int64
|
||||
for r := range set {
|
||||
if r.at.Before(cutoff) {
|
||||
delete(set, r)
|
||||
continue
|
||||
}
|
||||
if !r.at.Before(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
delete(f.m, key)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// reserve claims a slot for key until the message is recorded or released.
|
||||
func (f *inflight) reserve(key string) *reservation {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.m == nil {
|
||||
f.m = make(map[string]map[*reservation]struct{})
|
||||
}
|
||||
if f.m[key] == nil {
|
||||
f.m[key] = make(map[*reservation]struct{})
|
||||
}
|
||||
r := &reservation{key: key, at: time.Now()}
|
||||
f.m[key][r] = struct{}{}
|
||||
return r
|
||||
}
|
||||
|
||||
// release drops a reservation, either because the message reached the send log
|
||||
// (where the stored count takes over) or because it never will.
|
||||
func (f *inflight) release(r *reservation) {
|
||||
if f == nil || r == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
set := f.m[r.key]
|
||||
delete(set, r)
|
||||
if len(set) == 0 {
|
||||
delete(f.m, r.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package milter implements the SelfPost journal-milter: a lightweight milter
|
||||
// (architecture.md § Mail path) attached to Postfix's smtpd_milters alongside
|
||||
// OpenDKIM. On the receive path it reads the SASL login, From, recipients and
|
||||
// Subject of each accepted message and records one send-log row per (queue-id,
|
||||
// recipient), giving the panel a structured, filterable history that raw
|
||||
// mail.log cannot.
|
||||
//
|
||||
// It is monitoring only: it never rejects, and every callback returns Continue
|
||||
// or Accept so a failure of this milter can never block the relay. Postfix is
|
||||
// configured with default_action=accept for this milter's socket, so even a
|
||||
// crash or hang fails open (architecture.md § Mail path).
|
||||
package milter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-milter"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Store is the persistence the milter needs on the receive path: recording
|
||||
// accepted messages (architecture.md § Mail path) and, for level-2 rate
|
||||
// limiting (guide § Rate limiting), looking up the configured limits and
|
||||
// counting recent messages. *store.Store satisfies it; tests substitute a
|
||||
// fake.
|
||||
type Store interface {
|
||||
InsertQueued(e store.SendLogEntry) error
|
||||
InsertRejected(e store.SendLogEntry) error
|
||||
RateLimit(scope, ref string) (store.RateLimit, bool, error)
|
||||
CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// session accumulates the fields of one message as the milter callbacks fire.
|
||||
// Milter macros arrive per-stage and do not accumulate, so each value is
|
||||
// captured at the stage that carries it (architecture.md § Mail path): SASL
|
||||
// login and From at MAIL, each recipient at RCPT, Subject in the headers, and
|
||||
// the queue-id at end-of-message. go-milter creates one session per
|
||||
// connection; a connection may carry several messages, so per-message fields
|
||||
// are reset at MailFrom (the start of every transaction).
|
||||
type session struct {
|
||||
milter.NoOpMilter
|
||||
rec Store
|
||||
// flight is shared by every session of the process; it holds the messages
|
||||
// that passed the level-2 check but are not in the send log yet. Nil is a
|
||||
// valid zero value (no in-flight accounting).
|
||||
flight *inflight
|
||||
|
||||
clientIP string // captured once per connection
|
||||
|
||||
login string
|
||||
from string
|
||||
rcpts []string
|
||||
subject string
|
||||
reserved []*reservation // level-2 slots held by the current message
|
||||
}
|
||||
|
||||
// Connect captures the client IP, which comes from the addr parameter rather
|
||||
// than a macro (the {client_addr} macro was empty in testing). It is the
|
||||
// rate-limit key; here it is recorded for completeness.
|
||||
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
||||
if addr != nil {
|
||||
s.clientIP = addr.String()
|
||||
}
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
// MailFrom starts a new message: reset per-message state, then capture the
|
||||
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
|
||||
// macros). This is also the earliest stage where both the sending domain (from
|
||||
// the sender) and the application (the login) are known, so the level-2 rate
|
||||
// limit is enforced here: over the limit, the message is refused with a 4xx
|
||||
// tempfail before recipients are even offered (guide § Rate limiting).
|
||||
// Enforcement is fail-open — see overLimit.
|
||||
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
||||
s.from = cleanAddress(from)
|
||||
s.login = macro(m, "auth_authen")
|
||||
s.rcpts = nil
|
||||
s.subject = ""
|
||||
if s.overLimit() {
|
||||
s.recordRejected()
|
||||
return milter.RespTempFail, nil
|
||||
}
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
// RcptTo records each recipient. Postfix calls this once per recipient, which
|
||||
// is what lets the journal keep a separate row per (queue-id, recipient).
|
||||
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
// Header captures the Subject. Only the first Subject header is kept.
|
||||
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
|
||||
s.subject = mailhdr.DecodeSubject(value)
|
||||
}
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
// Body fires at end-of-message, when the queue-id macro {i} is set and the
|
||||
// message is about to be committed to the queue. This is where the "queued"
|
||||
// rows are written. We accept (this milter is done) without ever rejecting.
|
||||
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
||||
s.record(macro(m, "i"))
|
||||
// The rows are in the send log now, so the stored count sees this message
|
||||
// and its level-2 slots are no longer needed.
|
||||
s.releaseReservations()
|
||||
return milter.RespAccept, nil
|
||||
}
|
||||
|
||||
// Abort ends the current transaction without an end-of-message (client RSET, or
|
||||
// Postfix rejecting the message for its own reasons). No send-log row will be
|
||||
// written, so the level-2 slots this message held must go back.
|
||||
func (s *session) Abort(m *milter.Modifier) error {
|
||||
s.releaseReservations()
|
||||
s.rcpts = nil
|
||||
s.subject = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// macro reads a milter macro, tolerating Postfix's convention of wrapping
|
||||
// multi-character macro names in curly braces (e.g. {auth_authen}) while
|
||||
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
|
||||
// Postfix sends verbatim, so a lookup must try both forms.
|
||||
func macro(m *milter.Modifier, name string) string {
|
||||
if v, ok := m.Macros[name]; ok {
|
||||
return v
|
||||
}
|
||||
return m.Macros["{"+name+"}"]
|
||||
}
|
||||
|
||||
// record writes one send-log row per recipient. Failures are logged, never
|
||||
// propagated: journalling must not affect mail acceptance (architecture.md §
|
||||
// Mail path).
|
||||
func (s *session) record(queueID string) {
|
||||
domain := domainOf(s.from)
|
||||
rcpts := s.rcpts
|
||||
if len(rcpts) == 0 {
|
||||
// No recipient seen (unusual) — still record the message so it is
|
||||
// visible in the log rather than silently dropped.
|
||||
rcpts = []string{""}
|
||||
}
|
||||
for _, to := range rcpts {
|
||||
err := s.rec.InsertQueued(store.SendLogEntry{
|
||||
QueueID: queueID,
|
||||
Domain: domain,
|
||||
AppLogin: s.login,
|
||||
From: s.from,
|
||||
To: to,
|
||||
Subject: s.subject,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: record %s -> %s: %v", queueID, to, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanAddress strips the angle brackets and any ESMTP parameters Postfix may
|
||||
// pass with an address, leaving the bare mailbox.
|
||||
func cleanAddress(a string) string {
|
||||
a = strings.TrimSpace(a)
|
||||
if i := strings.IndexByte(a, ' '); i >= 0 { // drop "addr SIZE=… BODY=…" params
|
||||
a = a[:i]
|
||||
}
|
||||
a = strings.TrimPrefix(a, "<")
|
||||
a = strings.TrimSuffix(a, ">")
|
||||
return a
|
||||
}
|
||||
|
||||
// domainOf returns the lower-cased domain of an email address, or "" if there
|
||||
// is no domain part. Sender binding guarantees the From domain equals the
|
||||
// application's domain, so this is the sending domain (architecture.md § Mail
|
||||
// path).
|
||||
func domainOf(addr string) string {
|
||||
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
|
||||
return strings.ToLower(addr[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
|
||||
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
|
||||
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
||||
flight := &inflight{} // shared: the level-2 window spans all connections
|
||||
srv := &milter.Server{
|
||||
NewMilter: func() milter.Milter { return &session{rec: rec, flight: flight} },
|
||||
Actions: 0, // read-only: we make no message modifications
|
||||
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = srv.Close()
|
||||
}()
|
||||
|
||||
if err := srv.Serve(ln); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil // expected: Close() during shutdown unblocks Serve
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package milter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-milter"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// fakeRecorder captures inserts and can be made to fail, to prove the milter
|
||||
// swallows recorder errors and still accepts the message. By default it reports
|
||||
// no configured rate limit, so the level-2 check is inert unless a test sets
|
||||
// limits (see fakeRecorder fields).
|
||||
type fakeRecorder struct {
|
||||
entries []store.SendLogEntry
|
||||
rejected []store.SendLogEntry
|
||||
fail bool
|
||||
|
||||
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
|
||||
// gives the recent-message count returned for a "scope|ref". lookupErr and
|
||||
// countErr force the store errors that must fail open.
|
||||
limits map[string]store.RateLimit
|
||||
counts map[string]int64
|
||||
lookupErr error
|
||||
countErr error
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
||||
if f.fail {
|
||||
return errors.New("boom")
|
||||
}
|
||||
f.entries = append(f.entries, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
|
||||
f.rejected = append(f.rejected, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, error) {
|
||||
if f.lookupErr != nil {
|
||||
return store.RateLimit{}, false, f.lookupErr
|
||||
}
|
||||
rl, ok := f.limits[scope+"|"+ref]
|
||||
return rl, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, error) {
|
||||
if f.countErr != nil {
|
||||
return 0, f.countErr
|
||||
}
|
||||
return f.counts[scope+"|"+ref], nil
|
||||
}
|
||||
|
||||
func mods(kv map[string]string) *milter.Modifier {
|
||||
return &milter.Modifier{Macros: kv}
|
||||
}
|
||||
|
||||
// drive replays a typical message through one session and returns the recorder.
|
||||
func drive(t *testing.T, rec Store) *session {
|
||||
t.Helper()
|
||||
s := &session{rec: rec}
|
||||
if _, err := s.Connect("localhost", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
if _, err := s.MailFrom("noreply@example.com", mods(map[string]string{"auth_authen": "app1"})); err != nil {
|
||||
t.Fatalf("MailFrom: %v", err)
|
||||
}
|
||||
if _, err := s.RcptTo("<a@example.net>", mods(nil)); err != nil {
|
||||
t.Fatalf("RcptTo: %v", err)
|
||||
}
|
||||
if _, err := s.RcptTo("b@example.net", mods(nil)); err != nil {
|
||||
t.Fatalf("RcptTo: %v", err)
|
||||
}
|
||||
if _, err := s.Header("Subject", "Hello there", mods(nil)); err != nil {
|
||||
t.Fatalf("Header: %v", err)
|
||||
}
|
||||
if _, err := s.Body(mods(map[string]string{"i": "ABC123"})); err != nil {
|
||||
t.Fatalf("Body: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSessionRecordsRowPerRecipient(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
s := drive(t, rec)
|
||||
|
||||
if s.clientIP != "203.0.113.7" {
|
||||
t.Fatalf("clientIP = %q, want 203.0.113.7", s.clientIP)
|
||||
}
|
||||
if len(rec.entries) != 2 {
|
||||
t.Fatalf("want 2 entries, got %d: %+v", len(rec.entries), rec.entries)
|
||||
}
|
||||
got := rec.entries[0]
|
||||
want := store.SendLogEntry{
|
||||
QueueID: "ABC123",
|
||||
Domain: "example.com",
|
||||
AppLogin: "app1",
|
||||
From: "noreply@example.com",
|
||||
To: "a@example.net", // angle brackets stripped
|
||||
Subject: "Hello there",
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("entry[0]\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if rec.entries[1].To != "b@example.net" {
|
||||
t.Fatalf("entry[1].To = %q", rec.entries[1].To)
|
||||
}
|
||||
}
|
||||
|
||||
// A subject in any non-ASCII alphabet reaches the milter as RFC 2047
|
||||
// encoded-words; the journal stores the text, not the encoding. The decoding
|
||||
// itself is mailhdr's; what this checks is that Header runs it.
|
||||
func TestHeaderDecodesEncodedSubject(t *testing.T) {
|
||||
s := &session{rec: &fakeRecorder{}}
|
||||
if _, err := s.Header("Subject", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", mods(nil)); err != nil {
|
||||
t.Fatalf("Header: %v", err)
|
||||
}
|
||||
if s.subject != "Проверка" {
|
||||
t.Fatalf("subject = %q, want %q", s.subject, "Проверка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) {
|
||||
rec := &fakeRecorder{fail: true}
|
||||
s := &session{rec: rec}
|
||||
_, _ = s.MailFrom("x@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||
_, _ = s.RcptTo("y@example.net", mods(nil))
|
||||
resp, err := s.Body(mods(map[string]string{"i": "Q9"}))
|
||||
if err != nil {
|
||||
t.Fatalf("Body returned error, must fail open: %v", err)
|
||||
}
|
||||
if resp != milter.RespAccept {
|
||||
t.Fatalf("Body response = %v, want Accept", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// A single connection may carry several messages; the second must not inherit
|
||||
// the first's recipients or subject.
|
||||
func TestSessionResetsBetweenMessages(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
s := &session{rec: rec}
|
||||
|
||||
_, _ = s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||
_, _ = s.RcptTo("one@example.net", mods(nil))
|
||||
_, _ = s.Header("Subject", "first", mods(nil))
|
||||
_, _ = s.Body(mods(map[string]string{"i": "Q1"}))
|
||||
|
||||
_, _ = s.MailFrom("b@example.com", mods(map[string]string{"auth_authen": "app2"}))
|
||||
_, _ = s.RcptTo("two@example.net", mods(nil))
|
||||
_, _ = s.Body(mods(map[string]string{"i": "Q2"}))
|
||||
|
||||
if len(rec.entries) != 2 {
|
||||
t.Fatalf("want 2 entries, got %d", len(rec.entries))
|
||||
}
|
||||
second := rec.entries[1]
|
||||
if second.QueueID != "Q2" || second.To != "two@example.net" || second.Subject != "" || second.AppLogin != "app2" {
|
||||
t.Fatalf("second message leaked state: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
|
||||
// {i} for some versions), so the milter must resolve those too — this is the
|
||||
// case the SASL-less spike missed and that produced empty app_login at first.
|
||||
func TestBracedMacros(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
s := &session{rec: rec}
|
||||
_, _ = s.MailFrom("app@example.com", mods(map[string]string{"{auth_authen}": "app1"}))
|
||||
_, _ = s.RcptTo("to@example.net", mods(nil))
|
||||
_, _ = s.Body(mods(map[string]string{"{i}": "QBRACE"}))
|
||||
|
||||
if len(rec.entries) != 1 {
|
||||
t.Fatalf("want 1 entry, got %d", len(rec.entries))
|
||||
}
|
||||
e := rec.entries[0]
|
||||
if e.AppLogin != "app1" {
|
||||
t.Fatalf("AppLogin = %q, want app1 (braced {auth_authen} not resolved)", e.AppLogin)
|
||||
}
|
||||
if e.QueueID != "QBRACE" {
|
||||
t.Fatalf("QueueID = %q, want QBRACE (braced {i} not resolved)", e.QueueID)
|
||||
}
|
||||
}
|
||||
|
||||
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
||||
// register it so the differentiated check applies.
|
||||
const limitIP = "203.0.113.7"
|
||||
|
||||
func activeLimit(ips ...string) store.RateLimit {
|
||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||
}
|
||||
|
||||
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
|
||||
// which is where the level-2 limit is enforced.
|
||||
func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
||||
t.Helper()
|
||||
s := &session{rec: rec}
|
||||
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
|
||||
if err != nil {
|
||||
t.Fatalf("MailFrom: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("over-limit MAIL FROM = %v, want TempFail (4xx)", resp)
|
||||
}
|
||||
if len(rec.rejected) != 1 || rec.rejected[0].Domain != "example.com" {
|
||||
t.Fatalf("want one rejected send-log row for example.com, got %+v", rec.rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("over app limit = %v, want TempFail", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("under limit = %v, want Continue", resp)
|
||||
}
|
||||
if len(rec.rejected) != 0 {
|
||||
t.Fatalf("under limit must not record a rejection: %+v", rec.rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
// The sender's IP is not in the domain's registered set, so level-2 does not
|
||||
// apply even though the count is huge (level-1 anvil would still cover it).
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("inactive limit = %v, want Continue", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
||||
rec := &fakeRecorder{lookupErr: errors.New("db down")}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("lookup error = %v, want Continue (fail-open)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
},
|
||||
countErr: errors.New("db down"),
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("count error = %v, want Continue (fail-open)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
// A session with no client IP (e.g. local submission) cannot be keyed.
|
||||
s := &session{rec: rec}
|
||||
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||
if err != nil {
|
||||
t.Fatalf("MailFrom: %v", err)
|
||||
}
|
||||
if resp != milter.RespContinue {
|
||||
t.Fatalf("no-IP session = %v, want Continue", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// mailFromIn is mailFrom with an explicit shared in-flight registry, so a test
|
||||
// can play several concurrent SMTP sessions of one process against each other.
|
||||
func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (*session, milter.Response) {
|
||||
t.Helper()
|
||||
s := &session{rec: rec, flight: fl}
|
||||
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
|
||||
if err != nil {
|
||||
t.Fatalf("MailFrom: %v", err)
|
||||
}
|
||||
return s, resp
|
||||
}
|
||||
|
||||
func limitedRecorder(count int64) *fakeRecorder {
|
||||
return &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
||||
}
|
||||
}
|
||||
|
||||
// Messages between MAIL FROM and end-of-message are not in the send log yet, so
|
||||
// counting the stored rows alone lets concurrent sessions each pass the same
|
||||
// check and overshoot the ceiling. The last free slot may only be taken once.
|
||||
func TestRateLimitCountsInFlightMessages(t *testing.T) {
|
||||
rec := limitedRecorder(4) // one below the ceiling of 5
|
||||
fl := &inflight{}
|
||||
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("first message = %v, want Continue (4/5 stored)", resp)
|
||||
}
|
||||
// Same window, nothing written yet: the first message holds the fifth slot.
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("concurrent message = %v, want TempFail (would overshoot)", resp)
|
||||
}
|
||||
if len(rec.rejected) != 1 {
|
||||
t.Fatalf("want one rejected send-log row, got %+v", rec.rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the message is recorded the stored count sees it, so its reservation
|
||||
// must be given back — otherwise it would be counted twice and the ceiling
|
||||
// would drift closed.
|
||||
func TestReservationReleasedAtEndOfMessage(t *testing.T) {
|
||||
rec := limitedRecorder(4)
|
||||
fl := &inflight{}
|
||||
|
||||
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
|
||||
if resp != milter.RespContinue {
|
||||
t.Fatalf("first message = %v, want Continue", resp)
|
||||
}
|
||||
if _, err := s.Body(mods(map[string]string{"i": "Q1"})); err != nil {
|
||||
t.Fatalf("Body: %v", err)
|
||||
}
|
||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("in-flight count after EOM = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A transaction the client abandons (RSET, or a Postfix-side rejection) never
|
||||
// reaches the send log, so its slot must not stay claimed.
|
||||
func TestReservationReleasedOnAbort(t *testing.T) {
|
||||
rec := limitedRecorder(4)
|
||||
fl := &inflight{}
|
||||
|
||||
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
|
||||
if resp != milter.RespContinue {
|
||||
t.Fatalf("first message = %v, want Continue", resp)
|
||||
}
|
||||
if err := s.Abort(mods(nil)); err != nil {
|
||||
t.Fatalf("Abort: %v", err)
|
||||
}
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("after abort = %v, want Continue (slot released)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused message must not leave the slots it claimed for the limits checked
|
||||
// before the one that tripped, or every refusal would tighten the ceiling.
|
||||
func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{
|
||||
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
|
||||
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
|
||||
},
|
||||
}
|
||||
fl := &inflight{}
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("app over limit = %v, want TempFail", resp)
|
||||
}
|
||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The in-flight count only covers the limit's own window: a reservation older
|
||||
// than it (a session stuck mid-DATA for longer than the window) must not be
|
||||
// counted against a window it no longer belongs to.
|
||||
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
|
||||
fl := &inflight{}
|
||||
r := fl.reserve("domain|example.com")
|
||||
r.at = time.Now().Add(-time.Minute)
|
||||
|
||||
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
|
||||
t.Fatalf("count inside window = %d, want 1", n)
|
||||
}
|
||||
if n := fl.count("domain|example.com", time.Now().Add(-time.Second)); n != 0 {
|
||||
t.Fatalf("count outside window = %d, want 0", n)
|
||||
}
|
||||
// Past the TTL the reservation is dropped even for a wide window, so a
|
||||
// client that vanished after MAIL FROM cannot hold a slot forever.
|
||||
r.at = time.Now().Add(-2 * reservationTTL)
|
||||
if n := fl.count("domain|example.com", time.Now().Add(-3*reservationTTL)); n != 0 {
|
||||
t.Fatalf("expired reservation still counted: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainOf(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"user@Example.COM": "example.com",
|
||||
"no-domain": "",
|
||||
"": "",
|
||||
"a@b@c.com": "c.com",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := domainOf(in); got != want {
|
||||
t.Fatalf("domainOf(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package milter
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// overLimit reports whether the message currently being received should be
|
||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
||||
// checks the domain-level and application-level limits in turn; either being
|
||||
// exceeded is enough to refuse.
|
||||
//
|
||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||
// limiter can never block mail — Postfix's level-1 anvil limit
|
||||
// (architecture.md § Mail path) remains the backstop, and it does not depend
|
||||
// on this milter at all. Only a clean count at or above a configured ceiling
|
||||
// returns true.
|
||||
//
|
||||
// A message that passes reserves a slot per applicable limit, released once it
|
||||
// reaches the send log (or is abandoned) — see inflight for why the stored
|
||||
// count alone is not enough.
|
||||
func (s *session) overLimit() bool {
|
||||
if s.clientIP == "" {
|
||||
return false // no client IP to key on; level-2 does not apply
|
||||
}
|
||||
checks := []struct{ scope, ref string }{
|
||||
{store.RateLimitScopeDomain, domainOf(s.from)},
|
||||
{store.RateLimitScopeApp, s.login},
|
||||
}
|
||||
var taken []*reservation
|
||||
for _, c := range checks {
|
||||
if c.ref == "" {
|
||||
continue
|
||||
}
|
||||
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||
continue
|
||||
}
|
||||
// No limit configured, an inert draft, or a client IP outside the
|
||||
// registered set: the differentiated limit does not apply here.
|
||||
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
||||
continue
|
||||
}
|
||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||
continue
|
||||
}
|
||||
key := c.scope + "|" + c.ref
|
||||
n += s.flight.count(key, since)
|
||||
if n >= int64(rl.MaxMessages) {
|
||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||
// The message is refused, so the slots claimed for the limits
|
||||
// checked before this one must not stay claimed.
|
||||
for _, r := range taken {
|
||||
s.flight.release(r)
|
||||
}
|
||||
return true
|
||||
}
|
||||
taken = append(taken, s.flight.reserve(key))
|
||||
}
|
||||
s.reserved = append(s.reserved, taken...)
|
||||
return false
|
||||
}
|
||||
|
||||
// releaseReservations gives back every slot this message holds. It runs once
|
||||
// the message is in the send log (where the stored count sees it), and whenever
|
||||
// the transaction ends without getting there.
|
||||
func (s *session) releaseReservations() {
|
||||
for _, r := range s.reserved {
|
||||
s.flight.release(r)
|
||||
}
|
||||
s.reserved = nil
|
||||
}
|
||||
|
||||
// recordRejected writes a send-log row for a message refused by a level-2
|
||||
// limit (guide § Rate limiting — refusals are recorded too), so the rejection
|
||||
// shows up in the monitoring screen. Only MAIL-stage fields are known; the
|
||||
// write is best-effort and never affects the response.
|
||||
func (s *session) recordRejected() {
|
||||
err := s.rec.InsertRejected(store.SendLogEntry{
|
||||
Domain: domainOf(s.from),
|
||||
AppLogin: s.login,
|
||||
From: s.from,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: record rejected %s: %v", s.from, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package postfix owns the Postfix configuration files the panel edits at
|
||||
// runtime and the privileged reload that applies them (architecture.md § Mail
|
||||
// path, security.md): the smtpd_sender_login_maps table binding each
|
||||
// application's SASL login to the sender addresses it may use, plus the relay
|
||||
// configuration in main.cf.
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Postfix manages the on-disk Postfix state the panel is responsible for. After
|
||||
// rewriting a map it asks Postfix to reload.
|
||||
type Postfix struct {
|
||||
senderLoginMapsPath string
|
||||
|
||||
// reload asks the running Postfix to re-read its configuration. It is a
|
||||
// field so tests can substitute a no-op; the default drives supervisord.
|
||||
reload func() error
|
||||
}
|
||||
|
||||
// New builds a manager rooted at dir (typically /data/postfix), the same layout
|
||||
// entrypoint.sh prepares. The default reload path signals Postfix through
|
||||
// supervisord.
|
||||
func New(dir string) *Postfix {
|
||||
return &Postfix{
|
||||
senderLoginMapsPath: filepath.Join(dir, "sender_login_maps"),
|
||||
reload: reloadViaSupervisor,
|
||||
}
|
||||
}
|
||||
|
||||
// SenderLoginMapsPath is the absolute path of the generated map, so main.cf
|
||||
// can point smtpd_sender_login_maps at it.
|
||||
func (p *Postfix) SenderLoginMapsPath() string {
|
||||
return p.senderLoginMapsPath
|
||||
}
|
||||
|
||||
// Binding is one sender-address → login pair (architecture.md § Mail path).
|
||||
// Address is either a domain wildcard "@example.com" or a specific address
|
||||
// "alerts@example.com".
|
||||
type Binding struct {
|
||||
Address string
|
||||
Login string
|
||||
}
|
||||
|
||||
// RebuildSenderLoginMaps regenerates the sender_login_maps file from the full
|
||||
// set of bindings and reloads Postfix (architecture.md § Mail path). Full
|
||||
// regeneration (rather than incremental edits) keeps the file a pure function
|
||||
// of the registry, so add, edit and delete share one idempotent path. The file
|
||||
// is written atomically before the reload.
|
||||
//
|
||||
// Several applications may be authorised for the same address (many-to-one,
|
||||
// architecture.md § Mail path) — their logins are merged onto a single line as
|
||||
// a comma-separated list, which is how Postfix expects multiple owners of one
|
||||
// sender.
|
||||
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
|
||||
content, err := renderSenderLoginMaps(bindings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(p.senderLoginMapsPath, content, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
}
|
||||
|
||||
// Reload asks Postfix to re-read its configuration without regenerating any
|
||||
// file. It backs the panel's manual reload button (architecture.md § Panel
|
||||
// HTTP surface).
|
||||
func (p *Postfix) Reload() error {
|
||||
return p.reload()
|
||||
}
|
||||
|
||||
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
|
||||
// sorted for deterministic output and the logins under each key are sorted and
|
||||
// de-duplicated. Every address and login is re-checked for injection safety
|
||||
// before being written (security.md) — upstream validation already guarantees
|
||||
// this, but the writer refuses to emit anything unsafe as a hard backstop.
|
||||
func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
|
||||
byAddr := make(map[string][]string)
|
||||
order := make([]string, 0)
|
||||
for _, b := range bindings {
|
||||
if err := assertMapSafe(b.Address, b.Login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, seen := byAddr[b.Address]; !seen {
|
||||
order = append(order, b.Address)
|
||||
}
|
||||
byAddr[b.Address] = appendUnique(byAddr[b.Address], b.Login)
|
||||
}
|
||||
sort.Strings(order)
|
||||
|
||||
var sb strings.Builder
|
||||
for _, addr := range order {
|
||||
logins := byAddr[addr]
|
||||
sort.Strings(logins)
|
||||
// texthash format: <key><whitespace><value>. A comma-separated value lists
|
||||
// every login permitted to use this sender (architecture.md § Mail path).
|
||||
fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ","))
|
||||
}
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
func appendUnique(list []string, v string) []string {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, v)
|
||||
}
|
||||
|
||||
// assertMapSafe rejects any address/login value that could break out of a single
|
||||
// map line or inject a directive. Addresses are validated to a strict whitelist
|
||||
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
|
||||
// upstream (security.md); this is defence in depth against a validation gap ever
|
||||
// letting whitespace, a newline or a comma (the value separator) through into
|
||||
// the file (security.md).
|
||||
func assertMapSafe(address, login string) error {
|
||||
if address == "" || login == "" {
|
||||
return fmt.Errorf("postfix: empty address or login")
|
||||
}
|
||||
if strings.ContainsAny(address, " \t\r\n,:\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in address %q", address)
|
||||
}
|
||||
if strings.ContainsAny(login, " \t\r\n,:@\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in login %q", login)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadViaSupervisor asks supervisord (PID 1, running as root) to run the
|
||||
// one-shot `postfix-reload` program, which executes the canonical `postfix
|
||||
// reload` and re-reads main.cf/master.cf and the lookup tables they reference.
|
||||
// The panel runs unprivileged: it cannot run `postfix reload` itself, and it
|
||||
// cannot signal the Postfix master directly because `postfix start-fg` forks a
|
||||
// separate master whose PID supervisord does not track (a SIGHUP to the
|
||||
// supervised process would never reach it). Going through supervisord's
|
||||
// group-accessible control socket runs the reload as root without any panel
|
||||
// privilege (architecture.md § Mail path, security.md).
|
||||
//
|
||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||
// and it never goes through a shell (security.md).
|
||||
func reloadViaSupervisor() error {
|
||||
cmd := exec.Command("supervisorctl",
|
||||
"-c", "/etc/supervisor/supervisord.conf",
|
||||
"start", "postfix-reload")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// A reload already in flight is not a failure: that pending run reloads
|
||||
// Postfix after our file is in place (the file is written before this).
|
||||
if strings.Contains(string(out), "already started") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reload postfix via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderSenderLoginMaps(t *testing.T) {
|
||||
// Deliberately unsorted, with two logins sharing one wildcard key
|
||||
// (many-to-one, architecture.md § Mail path) to exercise merge + sort.
|
||||
bindings := []Binding{
|
||||
{"@zeta.example", "z1"},
|
||||
{"alerts@alpha.example", "a-listed"},
|
||||
{"@alpha.example", "a2"},
|
||||
{"@alpha.example", "a1"},
|
||||
}
|
||||
got, err := renderSenderLoginMaps(bindings)
|
||||
if err != nil {
|
||||
t.Fatalf("renderSenderLoginMaps: %v", err)
|
||||
}
|
||||
want := "@alpha.example a1,a2\n" +
|
||||
"@zeta.example z1\n" +
|
||||
"alerts@alpha.example a-listed\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("map =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSenderLoginMapsEmpty(t *testing.T) {
|
||||
got, err := renderSenderLoginMaps(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("renderSenderLoginMaps(nil): %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty map, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSenderLoginMapsDedupesLogin(t *testing.T) {
|
||||
bindings := []Binding{
|
||||
{"@a.example", "dup"},
|
||||
{"@a.example", "dup"},
|
||||
}
|
||||
got, err := renderSenderLoginMaps(bindings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "@a.example dup\n" {
|
||||
t.Errorf("map = %q, want single deduped login", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMapSafeRejectsInjection(t *testing.T) {
|
||||
bad := []struct{ addr, login string }{
|
||||
{"@exa mple.com", "log"},
|
||||
{"@example.com\nx y z", "log"},
|
||||
{"@example.com", "log,evil"},
|
||||
{"@example.com", "log in"},
|
||||
{"@example.com", "log@realm"}, // '@' would confuse sasldb realm handling
|
||||
{"", "log"},
|
||||
{"@example.com", ""},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if err := assertMapSafe(b.addr, b.login); err == nil {
|
||||
t.Errorf("assertMapSafe(%q,%q) = nil, want error", b.addr, b.login)
|
||||
}
|
||||
}
|
||||
if err := assertMapSafe("alerts@example.com", "app_1-x"); err != nil {
|
||||
t.Errorf("assertMapSafe of a clean pair errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPostfix(t *testing.T) (*Postfix, *int) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reloads := 0
|
||||
p := New(dir)
|
||||
p.reload = func() error { reloads++; return nil }
|
||||
return p, &reloads
|
||||
}
|
||||
|
||||
func TestRebuildSenderLoginMapsWritesAndReloads(t *testing.T) {
|
||||
p, reloads := newTestPostfix(t)
|
||||
if err := p.RebuildSenderLoginMaps([]Binding{{"@example.com", "app1"}}); err != nil {
|
||||
t.Fatalf("RebuildSenderLoginMaps: %v", err)
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("reload called %d times, want 1", *reloads)
|
||||
}
|
||||
data, err := os.ReadFile(p.senderLoginMapsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "@example.com app1\n" {
|
||||
t.Errorf("map file = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildRejectsUnsafeWithoutWriting(t *testing.T) {
|
||||
p, reloads := newTestPostfix(t)
|
||||
// Seed a known-good file so we can prove the failed rebuild left it untouched.
|
||||
if err := p.RebuildSenderLoginMaps([]Binding{{"@good.example", "ok"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(p.senderLoginMapsPath)
|
||||
|
||||
err := p.RebuildSenderLoginMaps([]Binding{{"@bad.example", "evil\nlogin"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected rebuild to reject unsafe login")
|
||||
}
|
||||
after, _ := os.ReadFile(p.senderLoginMapsPath)
|
||||
if string(after) != string(before) {
|
||||
t.Errorf("map file changed on failed rebuild: %q", after)
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("reload called %d times, want 1 (no reload on failure)", *reloads)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Queue returns Postfix's own human-readable mail-queue listing
|
||||
// (architecture.md § Panel HTTP surface): active, deferred and held messages,
|
||||
// exactly as an administrator would see via the CLI. The command takes a
|
||||
// single fixed flag and no user input, so it never goes through a shell
|
||||
// (security.md). The panel is responsible for escaping the output before
|
||||
// display (security.md); this function returns it as-is.
|
||||
func Queue() (string, error) {
|
||||
cmd := exec.Command("postqueue", "-p")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// QueueIDs returns the set of queue ids Postfix is still holding — everything
|
||||
// in the maildrop, incoming, active, deferred and hold queues. It answers the
|
||||
// one question the log-tailer's reconcile sweep asks about a send-log row stuck
|
||||
// at "queued": is Postfix still working on this message, or has it left the
|
||||
// queue without the panel ever seeing a delivery line for it (architecture.md §
|
||||
// Log tailer)?
|
||||
//
|
||||
// An error means the queue could not be listed and therefore says nothing about
|
||||
// any message; the caller must treat it as "no information", never as an empty
|
||||
// queue.
|
||||
func QueueIDs() (map[string]struct{}, error) {
|
||||
out, err := Queue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseQueueIDs(out), nil
|
||||
}
|
||||
|
||||
// queueEntryRe matches the first line of a `postqueue -p` entry, e.g.
|
||||
//
|
||||
// 3C5B04E6C1* 446 Thu Aug 7 10:12:31 app@example.com
|
||||
//
|
||||
// The id is at the start of the line, optionally flagged '*' (in the active
|
||||
// queue) or '!' (on hold), and is followed by the message size. Requiring the
|
||||
// size is what separates an entry from the listing's other left-margin lines:
|
||||
// the '-Queue ID-' header, the '-- 5 Kbytes in 2 Requests.' trailer, a deferred
|
||||
// entry's '(connect timed out)' reason, and 'Mail queue is empty'. Recipient
|
||||
// lines are indented and never match.
|
||||
var queueEntryRe = regexp.MustCompile(`^([0-9A-Za-z]+)[*!]?\s+\d+\s`)
|
||||
|
||||
func parseQueueIDs(listing string) map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
for _, line := range strings.Split(listing, "\n") {
|
||||
if m := queueEntryRe.FindStringSubmatch(line); m != nil {
|
||||
ids[m[1]] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package postfix
|
||||
|
||||
import "testing"
|
||||
|
||||
// The reconcile sweep decides whether a message is still Postfix's problem, so
|
||||
// the parser must pick queue ids out of a real listing and nothing else out of
|
||||
// it: not the header, not the byte-count trailer, and above all not a deferred
|
||||
// entry's reason line, which — unlike the recipient lines — starts at the left
|
||||
// margin just as an entry does.
|
||||
func TestParseQueueIDs(t *testing.T) {
|
||||
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
|
||||
rcpt@example.net
|
||||
|
||||
5B4A2C1D3E 446 Fri Aug 8 10:13:31 app@example.com
|
||||
(connect to mx.example.net[203.0.113.9]:25: Connection timed out)
|
||||
deferred@example.net
|
||||
|
||||
A1B2C3D4E5F! 891 Fri Aug 8 10:14:31 app@example.com
|
||||
held@example.net
|
||||
|
||||
-- 1 Kbytes in 3 Requests.
|
||||
`
|
||||
ids := parseQueueIDs(listing)
|
||||
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F"}
|
||||
for _, id := range want {
|
||||
if _, ok := ids[id]; !ok {
|
||||
t.Errorf("queue id %s not found in %v", id, ids)
|
||||
}
|
||||
}
|
||||
if len(ids) != len(want) {
|
||||
t.Errorf("got %d ids %v, want exactly %v", len(ids), ids, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty queue must come back as an empty set, not as a phantom id parsed out
|
||||
// of Postfix's prose — every stale row would otherwise be compared against a
|
||||
// listing that claims to hold a message called "Mail".
|
||||
func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
|
||||
if ids := parseQueueIDs("Mail queue is empty\n"); len(ids) != 0 {
|
||||
t.Errorf("got %v, want no ids", ids)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user