Compare commits
135
Commits
v1.0.0
..
af9867bc2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9867bc2f | ||
|
|
10f5b4e8e1 | ||
|
|
50f1e4b298 | ||
|
|
b29f4845ba | ||
|
|
1e4746e52f | ||
|
|
ab2cd885c4 | ||
|
|
4204ee279c | ||
|
|
81190a7aad | ||
|
|
5a41d34cac | ||
|
|
0aa7318a41 | ||
|
|
1b13487356 | ||
|
|
fb26f7656a | ||
|
|
d88a8a6cba | ||
|
|
aa9a95ce4a | ||
|
|
23d52bab7c | ||
|
|
b81a261d01 | ||
|
|
72126b63af | ||
|
|
4a51db277b | ||
|
|
6adbb41459 | ||
|
|
1e4623b6e1 | ||
|
|
bb26e5022e | ||
|
|
b4a9b93cf2 | ||
|
|
a8ded7ecc8 | ||
|
|
33ee08f7b6 | ||
|
|
27aeadc71d | ||
|
|
c1ec4fbd79 | ||
|
|
8538b8d5f6 | ||
|
|
909145e5c8 | ||
|
|
d0bf4a4226 | ||
|
|
0d98d92642 | ||
|
|
6218540211 | ||
|
|
705239568b | ||
|
|
46191172d6 | ||
|
|
b99f60c1d6 | ||
|
|
41c3e6e896 | ||
|
|
c6a75ce775 | ||
|
|
f47cf39802 | ||
|
|
9edb39a8ee | ||
|
|
f517e8da41 | ||
|
|
2f888dc7be | ||
|
|
5598b62e4e | ||
|
|
32f5085c48 | ||
|
|
5601f73622 | ||
|
|
e17b1680bd | ||
|
|
5b63da0e49 | ||
|
|
e9aaed1c7b | ||
|
|
1bd71c10d0 | ||
|
|
74acf09212 | ||
|
|
ba07decbb0 | ||
|
|
4fc5cff266 | ||
|
|
ac8c2af500 | ||
|
|
9e3f0d0fad | ||
|
|
403c76d17d | ||
|
|
d9ab7bfa45 | ||
|
|
18bfd148ec | ||
|
|
e70ba9046e | ||
|
|
02afa0fa80 | ||
|
|
97a0c7e508 | ||
|
|
efdc2d63fb | ||
|
|
8dfb483244 | ||
|
|
e6af1a7866 | ||
|
|
aab35f0679 | ||
|
|
0570608738 | ||
|
|
1cf8bfcbe2 | ||
|
|
4761991dd5 | ||
|
|
8355479e03 | ||
|
|
6216e430e9 | ||
|
|
a1e7e05d26 | ||
|
|
fae0068c4a | ||
|
|
b829625066 | ||
|
|
71048922b9 | ||
|
|
5caca024c3 | ||
|
|
8289ed3916 | ||
|
|
958d07fcdd | ||
|
|
f0cd804cb3 | ||
|
|
3950bd333b | ||
|
|
584f8cbc1c | ||
|
|
6671bd3393 | ||
|
|
00e36df553 | ||
|
|
b0ebe061b5 | ||
|
|
601e183e0c | ||
|
|
f9e259a66d | ||
|
|
44e79c4120 | ||
|
|
1d30605533 | ||
|
|
b87baa6dd6 | ||
|
|
9420e30d6f | ||
|
|
93cf1de3b7 | ||
|
|
6b8658602d | ||
|
|
3a520d9ec7 | ||
|
|
4015ecd70d | ||
|
|
ad014ecdc1 | ||
|
|
de6f7a88da | ||
|
|
5985977f21 | ||
|
|
9ff7514621 | ||
|
|
ae7f5258a6 | ||
|
|
5bd632c8e0 | ||
|
|
f1074eb526 | ||
|
|
bc005dea85 | ||
|
|
4b2f3880ef | ||
|
|
fae930a37a | ||
|
|
c443149623 | ||
|
|
5eaf665c01 | ||
|
|
7cc499d57c | ||
|
|
aeba3f8db1 | ||
|
|
d6e67ceb61 | ||
|
|
8ed20b0c98 | ||
|
|
bd7a3b123d | ||
|
|
669f928b4d | ||
|
|
f44f5333b3 | ||
|
|
652f1fe438 | ||
|
|
c9d726dd64 | ||
|
|
f59befdb00 | ||
|
|
15baa1e5d0 | ||
|
|
c9076655b9 | ||
|
|
e34d81498e | ||
|
|
155b721438 | ||
|
|
efaf016c5f | ||
|
|
2bdc0ea9a8 | ||
|
|
6c8bf0d3b3 | ||
|
|
c713f51f24 | ||
|
|
0ed36e9624 | ||
|
|
5178f80fdf | ||
|
|
10c41677bd | ||
|
|
01c754f857 | ||
|
|
691d3c255f | ||
|
|
295d1afb7d | ||
|
|
06c2014384 | ||
|
|
a4cfa11323 | ||
|
|
236cb07769 | ||
|
|
04993e0da3 | ||
|
|
7eb168f418 | ||
|
|
a1b6209470 | ||
|
|
44683a4996 | ||
|
|
870012514a | ||
|
|
012802d83d |
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
||||||
|
globs: docs/assets/panel-ui/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Panel UI system
|
||||||
|
|
||||||
|
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
||||||
|
|
||||||
|
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
||||||
|
|
||||||
|
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
||||||
|
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
||||||
|
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
||||||
|
- Confirm/login/user form → `measure`.
|
||||||
|
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
||||||
|
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
||||||
|
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
||||||
|
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
||||||
|
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
||||||
|
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
||||||
@@ -7,5 +7,4 @@ docs
|
|||||||
bin
|
bin
|
||||||
*.exe
|
*.exe
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
|
||||||
Makefile
|
Makefile
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
name: release
|
name: release
|
||||||
|
|
||||||
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
|
# Publishes an immutable, version-tagged image on ghcr.io.
|
||||||
# Ordinary commits do not publish anything — only a pushed tag matching
|
# Ordinary commits and bare git tag pushes do not publish anything. A published
|
||||||
# vX.Y.Z does. That tag is the single source the version comes from: it goes
|
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
|
||||||
# into both the image tag and the panel binary's -ldflags version, so the two
|
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
|
||||||
# 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:
|
# Native per-architecture builds (see docs/development.md), not qemu: running
|
||||||
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
|
||||||
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
|
||||||
# tag on its own native runner; a merge job then combines them into the one
|
# own native runner; a merge job then combines them into the one manifest tag
|
||||||
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
|
||||||
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
# then test) is deliberate: it means the bytes that get tagged are exactly the
|
||||||
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering
|
# bytes that passed e2e. Per-arch tags are pushed only so imagetools can
|
||||||
# in the registry as a side effect (harmless — the version tag's immutability,
|
# assemble the multi-arch manifest; merge removes them from GHCR so operators
|
||||||
# spec 10.1, is about that tag, not these).
|
# see a single version tag.
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags:
|
types: [published]
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Image version as X.Y.Z (no v prefix)."
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -32,9 +34,25 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Derive version from tag
|
- name: Derive a SemVer X.Y.Z version
|
||||||
id: version
|
id: version
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
env:
|
||||||
|
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$EVENT_NAME" = "release" ]; then
|
||||||
|
raw="${RELEASE_TAG:-}"
|
||||||
|
else
|
||||||
|
raw="${INPUT_VERSION:-}"
|
||||||
|
fi
|
||||||
|
raw="${raw#v}"
|
||||||
|
if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=${raw}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -49,6 +67,8 @@ jobs:
|
|||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: v${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -113,6 +133,10 @@ jobs:
|
|||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: v${{ needs.prepare.outputs.version }}
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to ghcr.io
|
- name: Log in to ghcr.io
|
||||||
@@ -152,3 +176,29 @@ jobs:
|
|||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
backoff=$((backoff * 2))
|
backoff=$((backoff * 2))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Remove per-arch tags from GHCR
|
||||||
|
# Side-effect tags for imagetools assembly only — not part of the public
|
||||||
|
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
|
||||||
|
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="${{ needs.prepare.outputs.version }}"
|
||||||
|
owner="${{ github.repository_owner }}"
|
||||||
|
pkg="${{ github.event.repository.name }}"
|
||||||
|
api="/users/${owner}/packages/container/${pkg}/versions"
|
||||||
|
for suffix in amd64 arm64; do
|
||||||
|
tag="${version}-${suffix}"
|
||||||
|
mapfile -t ids < <(gh api "$api" --paginate \
|
||||||
|
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
|
||||||
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
|
echo "no GHCR package version for tag ${tag}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
for id in "${ids[@]}"; do
|
||||||
|
echo "deleting GHCR package version ${id} (tag ${tag})"
|
||||||
|
gh api -X DELETE "${api}/${id}"
|
||||||
|
done
|
||||||
|
done
|
||||||
+954
-6
@@ -5,10 +5,950 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission
|
||||||
|
milter chain, DMARC reports are validated before they are stored, and the auto
|
||||||
|
rate limit is correct for a non-default level-1 window. Schema migration
|
||||||
|
`0010` (index only). Upgrading is a tag bump.
|
||||||
|
|
||||||
|
A full-tree code review on 2026-08-19 (architecture, code quality, security,
|
||||||
|
GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what
|
||||||
|
follows. Its four open items live in [roadmap.md](docs/roadmap.md); its working
|
||||||
|
document is not part of the release.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- README hero line and a **Positioning** block in `docs/product.md` record the
|
||||||
|
tagline (`YOUR SMTP. YOUR RULES.`) and hero copy; the stamp subtitle stays
|
||||||
|
`SELF-HOSTED SMTP RELAY`. `docs/assets/selfpost-proof.html` updated to match.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Roadmap item and givens for a panel visual redesign
|
||||||
|
([docs/plans/panel-redesign.md](docs/plans/panel-redesign.md)) — layout
|
||||||
|
grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component
|
||||||
|
library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate.
|
||||||
|
- Roadmap item and plan for `/preflight` installation check page
|
||||||
|
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
|
||||||
|
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
|
||||||
|
DKIM socket, test email). Status: candidate.
|
||||||
|
- SQLite migration `0010_send_log_app_login_index.sql` —
|
||||||
|
`idx_send_log_app_login_created_at`. The journal-milter counts an
|
||||||
|
application's messages on every `MAIL FROM`, and the domain page computes
|
||||||
|
per-application statistics on every render; both were scanning the
|
||||||
|
`created_at` range because `app_login` was unindexed.
|
||||||
|
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
|
||||||
|
(several reporters ship a single-entry zip; those reports previously counted
|
||||||
|
as parse failures).
|
||||||
|
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
|
||||||
|
inside the container for all four combinations of `INBOUND_RELAY_ENABLE` /
|
||||||
|
`DMARC_REPORTS_ENABLE` / `INBOUND_ANTISPAM_MILTER` and requires
|
||||||
|
`postfix check` to pass. The `1.9.1` crash-loop shipped in `1.4.0` and
|
||||||
|
survived three releases because the suite only ever exercised the flags-off
|
||||||
|
path.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
- The setup account's password is hashed at bcrypt cost `12` like every other
|
||||||
`unknown blob` (and similar) errors after layers already uploaded.
|
panel password; it was still being written at `bcrypt.DefaultCost` (10). The
|
||||||
|
work factor now has one definition, `auth.BcryptCost`. bcrypt records the
|
||||||
|
cost in the hash, so existing passwords keep verifying unchanged.
|
||||||
|
- Login/setup rate limiter: protecting blocked buckets from eviction is now
|
||||||
|
bounded. Blocked buckets are still never evicted to make room, but past
|
||||||
|
`maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an
|
||||||
|
attacker with many source addresses could grow the bucket map without limit
|
||||||
|
by making each address fail its way to blocked. The overflow is logged at
|
||||||
|
most once per window.
|
||||||
|
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
|
||||||
|
the listing, and therefore the panel's allocation, up to the queue).
|
||||||
|
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
|
||||||
|
still headed `v9` while the chain head is `v10`, and three links pointed at
|
||||||
|
plan files deleted when those features shipped.
|
||||||
|
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
|
||||||
|
context timeout so a stuck queue command cannot hang the queue panel path
|
||||||
|
indefinitely.
|
||||||
|
- DMARC panel layout classes now exist in
|
||||||
|
`internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`,
|
||||||
|
`.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and
|
||||||
|
attention highlighting render as intended.
|
||||||
|
- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing.
|
||||||
|
- User-role update no longer contains a duplicate "last global admin" demotion
|
||||||
|
guard; one check now returns a context-specific message for self-demotion.
|
||||||
|
- Settings flash text now reflects what actually changed and only mentions
|
||||||
|
session sign-out when the password changed.
|
||||||
|
- Login/setup rate-limiter bucket eviction now prefers the nearest-expiry bucket
|
||||||
|
and does not evict currently blocked buckets.
|
||||||
|
- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`)
|
||||||
|
instead of a hardcoded repository URL.
|
||||||
|
- Help drawer close controls include accessibility attributes (`role="button"`,
|
||||||
|
`aria-label`) for the scrim and close action.
|
||||||
|
- Password hashing now uses explicit bcrypt cost `12` in settings/user password
|
||||||
|
updates, instead of `bcrypt.DefaultCost`.
|
||||||
|
- Test coverage expanded for DMARC map token injection rejection and email
|
||||||
|
validation edge cases (ASCII/local-part/domain/freemail cases).
|
||||||
|
|
||||||
|
- **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is
|
||||||
|
now always overridden on `smtp/inet` — to the optional antispam milter, or
|
||||||
|
to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail
|
||||||
|
inherited main.cf's chain: OpenDKIM (strict, `tempfail`) could defer relayed
|
||||||
|
mail during a signing outage, and the journal-milter filed inbound mail into
|
||||||
|
the outbound send log under the **sender's** domain — so Deliveries showed
|
||||||
|
other people's mail, and a forged `From:` on an inbound message consumed that
|
||||||
|
sending domain's level-2 rate-limit budget. [architecture.md](docs/architecture.md)
|
||||||
|
already described the intended behaviour; the code now matches it.
|
||||||
|
- **DMARC reports are validated before they are stored.** A report is accepted
|
||||||
|
only when the domain in `policy_published` is a sending domain configured
|
||||||
|
here and, for a per-domain hosted address, matches that address's `+tag`.
|
||||||
|
The rua address form is predictable, so anyone able to reach it could
|
||||||
|
previously file reports under any domain, skew the panel's alignment figures
|
||||||
|
and the tighten-`p=` hint, and evict genuine reports through the 500-report
|
||||||
|
retention cap.
|
||||||
|
- Auto level-2 rate limit: the ceiling is now scaled to the level-1 window
|
||||||
|
before the multiplier is applied. `ceil(avg msg/h × multiplier)` was stored
|
||||||
|
as the ceiling for a window of `RATE_LIMIT_WINDOW_SECONDS`, so any window
|
||||||
|
other than the default `3600` shifted the effective allowance by
|
||||||
|
`window/3600` (a 1800s window allowed twice the intended rate). A domain
|
||||||
|
that has sent anything now also gets a floor of 1.
|
||||||
|
- Single-domain export no longer drops rate limits silently when the store
|
||||||
|
errors — the error is returned instead of being swallowed by `err == nil && ok`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
|
||||||
|
review left unimplemented, so no finding lives only in a plan file:
|
||||||
|
`csrf-tokens` (the recorded §4.4 decision that never became a task),
|
||||||
|
`template-data-typing`, `structured-logging`, and
|
||||||
|
`review-2026-08-followups` for the small remainder (backup re-auth, the
|
||||||
|
missing service/handler/error-page tests, and nine minor findings that were
|
||||||
|
never carried into the review's own phase tables). Each roadmap row stands on
|
||||||
|
its own — file, symptom, and what "done" means — and the declined finding
|
||||||
|
(`//go:build linux` for the `/proc` reader) is recorded as declined so it is
|
||||||
|
not raised again. The review's working document is gone from `docs/plans/`
|
||||||
|
now that nothing depends on it; git history keeps it.
|
||||||
|
- Extracted `internal/configsafe` — the empty-value and forbidden-character
|
||||||
|
check every generated config file shares. Each writer keeps its own forbidden
|
||||||
|
set next to the file it protects (an OpenDKIM table and a Postfix map break on
|
||||||
|
different characters); only the check itself is shared.
|
||||||
|
- Build, deploy and migration comments no longer cite the archived
|
||||||
|
specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone
|
||||||
|
survived in `build/`, `deploy/`, the first two migrations and the release
|
||||||
|
workflow; they are removed and the affected comment paragraphs re-wrapped.
|
||||||
|
Comments only — no build or configuration behaviour changes.
|
||||||
|
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
|
||||||
|
wrappers left behind when the two parsers were merged: nothing but their own
|
||||||
|
tests still called them.
|
||||||
|
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
|
||||||
|
`internal/supervisor` (supervisorctl start/signal wrappers), and reused them in
|
||||||
|
domain/postfix code paths.
|
||||||
|
- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history
|
||||||
|
instead of a removed plan file.
|
||||||
|
- [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009`
|
||||||
|
changed what the IP list *means* — permissive (those IPs got the application
|
||||||
|
ceiling) before 1.9.0, restrictive (every other IP refused) after. Its
|
||||||
|
conversion branch only fires for a database that carried application-scope
|
||||||
|
`allowed_ips` from before 1.9.0.
|
||||||
|
- Panel help, [guide.md](docs/guide.md) and [architecture.md](docs/architecture.md):
|
||||||
|
the client IP allow-list is a **sending** control, not an authentication
|
||||||
|
boundary — the SASL login still succeeds, the check runs in the
|
||||||
|
journal-milter at `MAIL FROM`, and it is fail-open like the rate limits.
|
||||||
|
architecture.md's rate-limit description still documented the pre-1.9.0
|
||||||
|
trusted-IP semantics.
|
||||||
|
- Removed unused duplicates of the inbound validators (`Host`, `Port`,
|
||||||
|
`TLSMode`, `RecipientMode`, `MailboxInDomain`, `NormalizeHost`) from
|
||||||
|
`internal/web/validate` — the live path uses `internal/inbound/validate.go`;
|
||||||
|
the copies were reachable only from their own tests.
|
||||||
|
- `gofmt` across `cmd/` and `internal/` (10 files had drifted); dead branch
|
||||||
|
removed from `postfix.inboundNexthop`.
|
||||||
|
- The planned inbound-antispam migration is renumbered `0011_inbound_spam_log.sql`.
|
||||||
|
- Operator and as-built docs aligned with the code after a full pass —
|
||||||
|
[architecture.md](docs/architecture.md) (port 25 opens for inbound relay
|
||||||
|
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
|
||||||
|
and map paths; `/help` and `/dmarc*` routes; four panel roles including
|
||||||
|
rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md)
|
||||||
|
and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined
|
||||||
|
listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs`
|
||||||
|
closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md)
|
||||||
|
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
|
||||||
|
last-updated note. No behaviour change.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
|
||||||
|
and `internal/web/static/favicon.png`; nothing referenced it).
|
||||||
|
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
|
||||||
|
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
|
||||||
|
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-08-19
|
||||||
|
|
||||||
|
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
|
||||||
|
milter are enabled. Schema-migration reference and roadmap plans for inbound
|
||||||
|
antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
|
||||||
|
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
|
||||||
|
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
|
||||||
|
`1.10.0`; Composer → Opus → Fable workflow.
|
||||||
|
- [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
|
||||||
|
plan for held-mail review/release (design TBD).
|
||||||
|
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
|
||||||
|
SQLite migration chain (`user_version` head, per-file history, legacy artefacts,
|
||||||
|
1.x rules, and planned 2.x squash gate).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
|
||||||
|
and `inbound-quarantine` (candidate) in the index.
|
||||||
|
- [docs/development.md](docs/development.md) — plan checklists may override
|
||||||
|
model routing with Composer → Opus → Fable.
|
||||||
|
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
|
||||||
|
and [docs/architecture.md](docs/architecture.md) link to the new file;
|
||||||
|
`schema-squash` defers gate thresholds to it instead of a stale v5 snapshot.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the
|
||||||
|
inbound antispam milter no longer crash-loops on start — `postconf -P` cannot
|
||||||
|
set `master.cf` `-o` values that contain whitespace, so port-25
|
||||||
|
`smtpd_recipient_restrictions` and `smtpd_milters` are patched directly; DMARC
|
||||||
|
pipe `argv` uses a `dmarc-ingest` symlink instead of `panel -dmarc-ingest`.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-08-18
|
||||||
|
|
||||||
|
Application sending controls: client IP allow-list for authorization, and
|
||||||
|
level-2 rate limits that override the domain ceiling per application.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Per-application **client IP allow-list** on the domain page: when enabled,
|
||||||
|
only listed addresses may authenticate and submit as that application; when
|
||||||
|
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
|
||||||
|
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
|
||||||
|
from `rate_limits` into `applications.auth_allowed_ips`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Application level-2 rate limit **overrides** the domain limit for that login
|
||||||
|
— the ceiling may be higher or lower than the domain setting (still ≤ level
|
||||||
|
1). Rate limits are no longer tied to client IPs; enforcement is in the
|
||||||
|
journal-milter at `MAIL FROM`.
|
||||||
|
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
|
||||||
|
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
|
||||||
|
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
|
||||||
|
and the IP allow-list boundary.
|
||||||
|
- [guide.md](docs/guide.md): full-backup section documents all four capture
|
||||||
|
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
|
||||||
|
stopped `tar` of `./data` only), warns against `tar` on a live container, and
|
||||||
|
compares downtime, archive contents, and restore behaviour in a table.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-08-18
|
||||||
|
|
||||||
|
Built-in operator documentation in the panel: a CSS-only help drawer and Help
|
||||||
|
page so Status checks and domain controls are explained without leaving the
|
||||||
|
panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Help** navigation entry and `/help` index page.
|
||||||
|
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
|
||||||
|
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
|
||||||
|
DMARC, connection, applications, settings, export).
|
||||||
|
- «?» entry points on Status and domain card heads open the matching drawer
|
||||||
|
topic.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
|
||||||
|
meaning; the repository guide for installation, env vars, and operations.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-08-18
|
||||||
|
|
||||||
|
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
||||||
|
gzip/XML into SQLite, and show summaries in the panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
|
||||||
|
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
|
||||||
|
the panel shows per-domain roll-ups and individual reports under *DMARC*.
|
||||||
|
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
|
||||||
|
(`dmarc-reports+<domain>@<hostname>`).
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-08-18
|
||||||
|
|
||||||
|
30-day sending statistics per domain and application, plus optional auto
|
||||||
|
level-2 rate limits derived from average send rate.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Domain page **Sending statistics** card: total messages, peak and average
|
||||||
|
msg/h over the last 30 days (or shorter when send-log retention is below
|
||||||
|
30 days). Per-application stats in the application list.
|
||||||
|
- Level-2 rate limit **Manual** / **Auto** mode on domain and application
|
||||||
|
forms. Auto sets `max_messages` from `ceil(avg × multiplier)` over the
|
||||||
|
level-1 window, capped at level 1; background recalc every six hours and
|
||||||
|
**Recalculate now** on the domain page.
|
||||||
|
- Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
|
||||||
|
ceilings, trusted IPs).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual
|
||||||
|
mode behaviour is unchanged.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-08-18
|
||||||
|
|
||||||
|
Send-log retention is configurable from panel **Settings** (global
|
||||||
|
administrator); `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Panel **Settings** (global administrator): **Send log retention (days)** —
|
||||||
|
how long delivery journal rows on `/deliveries` are kept (7–365). Stored in
|
||||||
|
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
||||||
|
The log-tailer re-reads the value every prune cycle (no restart).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deliveries list and delivery detail pages show the configured retention
|
||||||
|
instead of a hardcoded ninety-day message.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-08-17
|
||||||
|
|
||||||
|
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
|
||||||
|
Mail is accepted only for domains configured in the panel and forwarded to
|
||||||
|
an upstream; there are still no mailboxes. A dedicated security review of
|
||||||
|
the inbound path is still open on the plan (Fable).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
|
||||||
|
port 25 without SASL, only for `relay_domains` + known recipients, and
|
||||||
|
forwards via `transport_maps`. Empty upstream host is omitted from the
|
||||||
|
maps so mail is never accepted with nowhere to send it.
|
||||||
|
- Panel **Inbound** pages (global administrator): domains, upstream host/port
|
||||||
|
and TLS, recipient list or any-at-domain, MX check against
|
||||||
|
`SELFPOST_HOSTNAME`.
|
||||||
|
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
|
||||||
|
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
|
||||||
|
Default milter action is fail-open (`accept`).
|
||||||
|
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
|
||||||
|
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Compose publishes host port 25 even when the flag is off (nothing listens
|
||||||
|
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
|
||||||
|
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
|
||||||
|
Single-domain export/import is still sending domains only.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-08-17
|
||||||
|
|
||||||
|
Retry policy in the panel, persistent Postfix queue, and self-contained
|
||||||
|
full backups. Mail queue and delivery history show this Postfix's first
|
||||||
|
retry, backoff cap, and queue lifetime. Deferred mail survives container
|
||||||
|
recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`;
|
||||||
|
the archive layout breaks 1.3.0 flat backups (unpack those with
|
||||||
|
`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a
|
||||||
|
tag bump; no schema migration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Mail queue and a delivery's history show this Postfix's retry policy
|
||||||
|
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
|
||||||
|
at panel start. A manual `postconf -e` override is visible after the next
|
||||||
|
panel restart. There is no attempt counter — Postfix retries on time.
|
||||||
|
|
||||||
|
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
|
||||||
|
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
|
||||||
|
CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage.
|
||||||
|
- docs: **Implementation checklists** with model routing in
|
||||||
|
[docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention,
|
||||||
|
domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
|
||||||
|
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
|
||||||
|
journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per
|
||||||
|
domain/application, auto level-2 rate limit from avg × multiplier) — plans in
|
||||||
|
[docs/plans/](docs/plans/).
|
||||||
|
|
||||||
|
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
|
||||||
|
container recreate and are included in full backups.
|
||||||
|
- Full backup archives the whole operator project: `data/`, `docker-compose.yml`,
|
||||||
|
`.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore
|
||||||
|
by unpacking into an empty project directory.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
|
||||||
|
deploy files sit at the archive root. Old flat archives restore with
|
||||||
|
`tar xzf backup.tar.gz -C ./data` as before.
|
||||||
|
- docs: operator guide, architecture, security, and backup UI updated for
|
||||||
|
self-contained backups and persistent queue.
|
||||||
|
|
||||||
|
- docs: HTML mockups for a full panel UI refresh
|
||||||
|
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
|
||||||
|
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
|
||||||
|
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
|
||||||
|
keep a reading measure), and a 390px emergency layout. The stamp and brick
|
||||||
|
palette stay; extra nav icons are proposals only. Design artefact; the
|
||||||
|
running panel is unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- docs: panel UI mockups after review — Status restored to live readings
|
||||||
|
(queue line, PTR, machine/process tables, Configuration); inbound MX DNS
|
||||||
|
check, recipient mode (list or any), and Danger zone beside recipients;
|
||||||
|
Backup and Settings as two-column ops pages; Save and Delete user on one
|
||||||
|
row; help «?» on domain cards; Host/name ‖ Type height matched to the live
|
||||||
|
panel (`b0ebe06`).
|
||||||
|
|
||||||
|
- docs: panel UI design system and mockups rebuilt as separate pages
|
||||||
|
(`system.html`, `status.html`, `domain.html`, …), not one hash sheet.
|
||||||
|
Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is
|
||||||
|
`field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`.
|
||||||
|
|
||||||
|
- docs: panel UI mockups — add-domain sits in the list card (Domains and
|
||||||
|
Inbound); DMARC candidate screens drill into a domain roll-up and a
|
||||||
|
parsed aggregate report (aligned vs third-party fail), not a hub-only
|
||||||
|
summary.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
|
||||||
|
after the manifest merge via the GitHub Packages API. The `[1.3.0]` merge job
|
||||||
|
called `docker buildx imagetools rm`, which is not a valid subcommand — cleanup
|
||||||
|
failed with a warning and the side-effect tags stayed in the registry until
|
||||||
|
this fix.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-08-14
|
||||||
|
|
||||||
|
Security and quality after 1.2.5: domain-admin send-log authorization,
|
||||||
|
fail-closed sign-in and application delete, level-2 rate-limit race fix,
|
||||||
|
restore Resync, expanded tests, operator docs, release CI, and OFL for IBM
|
||||||
|
Plex. Upgrading from 1.2.x is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
|
||||||
|
WOFF2 files (`internal/web/view/static/OFL.txt`). The image copies it next
|
||||||
|
to LICENSE and NOTICE under `/usr/share/doc/selfpost/`; the panel serves it
|
||||||
|
at `/static/OFL.txt`. OFL requires the licence to accompany the font.
|
||||||
|
|
||||||
|
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
|
||||||
|
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
|
||||||
|
delivery's history, reading `postconf -h` once at panel start so a manual
|
||||||
|
override is visible. Plan: `docs/plans/queue-retries.md` (removed once shipped).
|
||||||
|
Explanation only; no attempt counter and no panel knobs. Not yet
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The independent security review of the send-log authorization and
|
||||||
|
fail-closed fixes below (code-review plan § P7; reviewer model ≠ author
|
||||||
|
model) found no further issues: the domain scope holds on every query path,
|
||||||
|
a rate-limit refusal cannot consume window budget, and each failure residue
|
||||||
|
of the reordered application delete fails safe. Nothing was added to
|
||||||
|
[docs/security.md](docs/security.md) § Accepted risks; the review is
|
||||||
|
recorded in that file's header.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- test (e2e): send-log status scrapers follow the badge markup in
|
||||||
|
`deliveries_rows`. The release gate still looked for bare `<td>sent</td>`
|
||||||
|
after the panel started rendering status as `<span class="st st-*">` badges,
|
||||||
|
so `send_verify_dkim_and_status` timed out even when mail was delivered and
|
||||||
|
logged. A handler regression test catches this drift in `go test ./...`
|
||||||
|
without Docker.
|
||||||
|
|
||||||
|
- security (panel): the Deliveries list is scoped to a domain administrator's
|
||||||
|
assigned domains for every number of assignments. Previously the send log was
|
||||||
|
narrowed only when exactly one domain was assigned, so an administrator with
|
||||||
|
none or with two or more read every domain's rows (sender, recipient, subject)
|
||||||
|
on the list and its polled fragment. The domain scope is now an `IN`
|
||||||
|
constraint carried by the store query — a filter that states no scope returns
|
||||||
|
nothing — and the `domain` and `app` query parameters are checked against the
|
||||||
|
principal's own domains and applications before the query runs, so a
|
||||||
|
hand-written URL cannot widen the scope. Global administrators are unaffected.
|
||||||
|
|
||||||
|
- mail (level-2 rate limit): the ceiling is no longer overshot by messages that
|
||||||
|
arrive at the same instant. The milter counted the stored and in-flight
|
||||||
|
messages and reserved its own slot in two separate steps, so several SMTP
|
||||||
|
sessions could pass the same check before any of them had reserved. Counting
|
||||||
|
and reserving now happen as one operation, and the ceiling is handed out
|
||||||
|
exactly as many times as configured. Postfix's level-1 limit remains the
|
||||||
|
backstop and the level-2 check stays fail-open on store errors.
|
||||||
|
|
||||||
|
- panel (sign-in): a session that cannot be written to the database no longer
|
||||||
|
produces a session cookie. The login used to log the failure, set the cookie
|
||||||
|
and redirect to the dashboard, leaving the browser looking signed in while
|
||||||
|
every request bounced back to `/login`; it now fails closed with an error on
|
||||||
|
the sign-in page.
|
||||||
|
|
||||||
|
- panel (applications): deleting an application removes its SASL credentials
|
||||||
|
before its registry row. If `saslpasswd2` fails, the application stays listed
|
||||||
|
and the delete can be retried, instead of leaving a hidden account that could
|
||||||
|
still authenticate to Postfix. This matches the order domain deletion already
|
||||||
|
used.
|
||||||
|
|
||||||
|
- panel (GUI): a rejected rate-limit change on a domain's page now renders on
|
||||||
|
the danger surface (`.flash.error`) instead of the success one — it was
|
||||||
|
green with red text, reading as good news. Deleting a panel user now goes
|
||||||
|
through a confirmation page, the same pattern as domain deletion, instead of
|
||||||
|
a plain submit button next to Save with no confirmation at all.
|
||||||
|
|
||||||
|
- panel (restore): after a backup is extracted and the version guard passes,
|
||||||
|
the panel runs one mail-path Resync on the first boot — OpenDKIM's tables
|
||||||
|
and Postfix's sender map are re-derived from SQLite and the daemons are
|
||||||
|
reloaded, so drift between the archive and the database is healed before
|
||||||
|
mail flows. Later starts skip that step; the Status page Reload button runs
|
||||||
|
the same Resync on demand. The `internal/backup` package comment now matches
|
||||||
|
this behaviour.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- docs: operator and as-built docs aligned with the code after a full
|
||||||
|
pass — [architecture.md](docs/architecture.md) route table now marks
|
||||||
|
**global** routes (404 for domain administrators) and documents the
|
||||||
|
one-time restore Resync in Persistence; session/password and restore-session
|
||||||
|
wording corrected in [guide.md](docs/guide.md) and architecture (own-password
|
||||||
|
change vs admin reset, no "logout everywhere", immediate session restore on
|
||||||
|
the next request, PTR cache ≈1 min, decrypt has no version check, restore
|
||||||
|
Resync on first boot, domain add/delete/import and `POST /reload` global-only,
|
||||||
|
Settings DMARC global-only); [README.md](README.md) port-587 and quick-start
|
||||||
|
volume wording fixed; [security.md](docs/security.md) CSRF ADR points at
|
||||||
|
`authz.go` for route gating. No behaviour change.
|
||||||
|
|
||||||
|
- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
|
||||||
|
administration**, and **Domain administration** — DNS setup, operations,
|
||||||
|
rate limiting, and backup sections follow the instance/domain boundary
|
||||||
|
instead of mixing them. **Installation** now reads Ports → Local trial →
|
||||||
|
Initial setup → Full deployment (with the fixed image tag nested under it) →
|
||||||
|
Environment variables → Reverse proxy; the step-by-step production deploy and
|
||||||
|
per-proxy TLS commands move here from README's "Reference deploy" (README
|
||||||
|
keeps a short pointer). Internal (non-operator) environment variables move
|
||||||
|
to [architecture.md](docs/architecture.md) § Configuration; the guide keeps
|
||||||
|
a one-line pointer. **Full backup and restore** gains worked commands for
|
||||||
|
in-place restore, move-to-a-new-host, and encrypted-backup decrypt-first,
|
||||||
|
plus the version-mismatch error text. README anchors updated for the new
|
||||||
|
headings. No behaviour change.
|
||||||
|
|
||||||
|
- docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
|
||||||
|
is closed — and `docs/plans/code-review.md` is deleted per its own exit
|
||||||
|
criteria (history in git and in this file). The plan covered architecture,
|
||||||
|
quality, GUI, tests, and licence work; P0 was domain-admin send-log
|
||||||
|
authorization. The [roadmap](docs/roadmap.md)'s recommended order returns to
|
||||||
|
**queue-retries** and then **inbound-relay**; it still records
|
||||||
|
**schema-squash** (replace the 1.x SQLite migration chain with a 2.x baseline;
|
||||||
|
not a reason to cut a major on its own).
|
||||||
|
|
||||||
|
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
|
||||||
|
`internal/legal/legal.go` (the value the panel footer actually injects), not
|
||||||
|
`layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
|
||||||
|
packages were dropped so the tree is consistent; AGPL-3.0 does not require
|
||||||
|
them ([development.md](docs/development.md) § External libraries). Deleted the
|
||||||
|
completed `docs/plans/logrotate-mode.md` (history in git and
|
||||||
|
[1.2.3](#123---2026-08-12)).
|
||||||
|
|
||||||
|
- ci: the release image is published only for a **published** GitHub Release
|
||||||
|
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
|
||||||
|
bare git tag push no longer starts the build. `release.yml` listens for
|
||||||
|
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
|
||||||
|
native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one
|
||||||
|
manifest. Reliable removal of the per-arch side-effect tags from GHCR landed
|
||||||
|
in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails
|
||||||
|
in `prepare`. [development.md](docs/development.md) documents draft vs
|
||||||
|
published releases, why deleting a release tag converts it back to draft, and
|
||||||
|
Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a
|
||||||
|
mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow).
|
||||||
|
|
||||||
|
- test: the authorization and sign-in surfaces that had no tests now have them.
|
||||||
|
The login limiter is covered for its ceiling, its per-address scope, the reset
|
||||||
|
at the end of a window and the sweep that keeps finished buckets out of
|
||||||
|
memory; sign-in for a successful session, for refusals that do not reveal
|
||||||
|
which usernames exist, and for a lockout that a correct password cannot
|
||||||
|
bypass; the one-time setup link for creating the first administrator, closing
|
||||||
|
afterwards, rejecting a wrong or expired token, and refusing credentials the
|
||||||
|
panel would not accept later. Every global-only route (`/users`, `/backup`,
|
||||||
|
domain import, `/status`, `/mail-queue`, `/system-log`, domain add and delete,
|
||||||
|
reload) is checked to answer a domain administrator — and a request with no
|
||||||
|
principal — with 404, the check that would have caught the send-log leak.
|
||||||
|
|
||||||
|
- test: restore is covered as the operator performs it, in process. A backup is
|
||||||
|
downloaded from a running panel through `POST /backup` (plain and encrypted),
|
||||||
|
unpacked the way `tar -xzf` unpacks it onto the `/data` bind mount, and a
|
||||||
|
second panel is booted on the result through the startup order the panel
|
||||||
|
itself uses — version guard, database, one Resync when restoring, then
|
||||||
|
services and the HTTP application. The restored panel shows the domain and
|
||||||
|
journal the archive carried, finds the DKIM key, SASL database and Postfix
|
||||||
|
sender map where its configuration says they are, does not reopen the
|
||||||
|
one-time setup link, and still honours a session that predates the backup.
|
||||||
|
Drifted on-disk maps are healed by that Resync step
|
||||||
|
(`TestResyncAfterRestoreHealsDriftedMaps`). A data directory left by another
|
||||||
|
version is refused with both versions named and the manifest kept. `serveHTTP`
|
||||||
|
is split in two so that composition can be started without binding a port; no
|
||||||
|
behaviour change.
|
||||||
|
|
||||||
|
- test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so
|
||||||
|
the release gate cannot change under a commit between two runs. The level-1
|
||||||
|
rate-limit failure message quoted `RATE_LIMIT_MESSAGES_PER_IP=5` while the
|
||||||
|
stand sets `50`.
|
||||||
|
|
||||||
|
- ci: gofmt on eight files that failed the formatting workflow check (panel
|
||||||
|
config, DNS check, domain transfer export, rate-limit tests, auth principal,
|
||||||
|
domain and delivery handlers, web package doc comment).
|
||||||
|
|
||||||
|
- panel (templates): the repeated Host/Type/Value DNS record markup on a
|
||||||
|
domain's page and the duplicated credentials form on Settings are now
|
||||||
|
shared partials (`host_type`, `host_type_copy`, `field_value`,
|
||||||
|
`field_values`, `credentials_fields`) instead of copy-pasted blocks. No
|
||||||
|
behaviour or visible change.
|
||||||
|
|
||||||
|
- panel (GUI, accessibility): the Deliveries fragment's `hx-get` and pagination
|
||||||
|
links now `urlquery`-encode the `domain`/`app` filters instead of splicing
|
||||||
|
them into the query string raw. The four polled regions (deliveries rows,
|
||||||
|
status, mail queue, system log) carry `aria-live="polite"` so a screen
|
||||||
|
reader announces the refreshed content.
|
||||||
|
|
||||||
|
- docs: [security.md](docs/security.md) accepted risks now note that
|
||||||
|
`data-confirm` prompts on destructive forms are JavaScript-only — with
|
||||||
|
JavaScript disabled the form submits immediately, the same as before the
|
||||||
|
prompts existed — and why that is acceptable (the prompt is a mis-click
|
||||||
|
guard, not an authorization boundary).
|
||||||
|
|
||||||
|
- docs: security and operator docs updated for the panel that has shipped
|
||||||
|
global administrators and domain-admins since 1.2.0. The CSRF ADR in
|
||||||
|
[security.md](docs/security.md) no longer argues from "single-user"; it now
|
||||||
|
states that cross-user CSRF between panel roles is not the threat the origin
|
||||||
|
check defends against, and gives a new revisit trigger. Dropped the
|
||||||
|
unimplemented "or argon2" alternative for the password hash.
|
||||||
|
[guide.md](docs/guide.md) documents the Users page and the two roles,
|
||||||
|
level-2 rate limiting's fail-open behaviour, and that a domain-admin can
|
||||||
|
export working SASL passwords for domains assigned to them.
|
||||||
|
[architecture.md](docs/architecture.md) gains `/license` and the
|
||||||
|
`/account` → `/settings` redirect in the route table (later expanded for
|
||||||
|
RBAC in the doc-alignment pass above). Corrected stale
|
||||||
|
`admin.dmarc_report_email` references in
|
||||||
|
[roadmap.md](docs/roadmap.md) and
|
||||||
|
`docs/plans/dmarc-reports.md` (removed once shipped) to the setting's
|
||||||
|
actual home after migration `0005`. No behaviour change.
|
||||||
|
|
||||||
|
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
|
||||||
|
gone (handlers already call `requireGlobal`); the settings route handler is
|
||||||
|
named `HandleSettings` in `handlers_settings.go`; domain lists for a
|
||||||
|
domain-admin now come from `ListDomainsForUser` in SQL instead of loading
|
||||||
|
every domain and filtering in Go; the login and setup rate limiters sweep
|
||||||
|
expired buckets on a timer and cap the map at 4096 keys; the five
|
||||||
|
show/hide field helpers in `panel.js` are one rule table; DMARC copy no
|
||||||
|
longer promises in-panel report reception in a future release — SelfPost
|
||||||
|
does not receive inbound mail.
|
||||||
|
|
||||||
|
## [1.2.5] - 2026-08-13
|
||||||
|
|
||||||
|
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: rate-limit forms refined — level-1 backstop as muted copy on the
|
||||||
|
domain settings form (`N messages / Ws — Settings`) and in message-limit
|
||||||
|
labels (`max N`); Settings shows the L1 ceiling in a code-row. Domain,
|
||||||
|
application, and Edit-toggle limit state use the shared `st` badge instead of
|
||||||
|
bold text or parenthetical copy. Address mode and trusted-IP override columns
|
||||||
|
carry muted leads and matched control height; trusted-IP help sits under the
|
||||||
|
IP field. Domain settings pairs DMARC reports with the level-2 rate limit
|
||||||
|
using CSS subgrid so titles, fields, and Save / Remove buttons line up across
|
||||||
|
columns. Domain page Export and Danger cards stack naturally again — a
|
||||||
|
shared-baseline flex pin on `.split` cards made mismatched body lengths look
|
||||||
|
worse than a plain stack.
|
||||||
|
|
||||||
|
## [1.2.4] - 2026-08-12
|
||||||
|
|
||||||
|
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field
|
||||||
|
height fix. Upgrading is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- rate limiting (level 2): domain ceilings apply to every client IP (no IP
|
||||||
|
allowlist). An application ceiling with trusted IPs is an override
|
||||||
|
**above** the domain limit (still ≤ level 1) and skips the domain check for
|
||||||
|
those IPs; without IPs the application override is inactive. When no domain
|
||||||
|
ceiling is set, non-privileged senders use level 1 only. The panel shows the
|
||||||
|
level-1 backstop on domain/application forms and Settings, rejects maxima
|
||||||
|
above level 1, and requires an application override to exceed the domain
|
||||||
|
maximum. Operator guide and architecture updated.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: on the domain DNS status grid, the Type (TXT) field height matches
|
||||||
|
the Host fields.
|
||||||
|
|
||||||
|
## [1.2.3] - 2026-08-12
|
||||||
|
|
||||||
|
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
|
||||||
|
connection settings ‖ add application; export ‖ danger). DNS status,
|
||||||
|
Applications and Domain settings are full-width. DNS status is two rows
|
||||||
|
(DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
|
||||||
|
and a Value label when records are present. Domain settings pairs DMARC
|
||||||
|
report mode with the optional level-2 domain rate limit; application Edit
|
||||||
|
opens address mode and an optional level-2 application rate limit side by
|
||||||
|
side (with a note that domain level-2 and global level-1 still apply); the
|
||||||
|
custom `rua=` address field is shown only for Custom address. The in-nav
|
||||||
|
“On this page” section index is removed. Shorter blurbs; *Sending server
|
||||||
|
settings* renamed **Connection settings**.
|
||||||
|
- panel: Domains list — **Add domain** sits beside the domain field; the
|
||||||
|
lead blurb under the form is dropped.
|
||||||
|
- panel: page URLs, browser titles, and headings are aligned — **Settings** is
|
||||||
|
now `/settings` (legacy `/account` redirects with 308); the domains list title
|
||||||
|
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
|
||||||
|
nav labels and `<h1>` text; the backup page title is `SelfPost — backup &
|
||||||
|
migration` to reflect domain import as well as full backup. Operator guide and
|
||||||
|
architecture route tables updated.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- image: `mail.log` rotation no longer silently stops when the build context
|
||||||
|
ships `logrotate-mail.conf` with group/other write bits (common after a
|
||||||
|
Windows checkout sync). Runtime `COPY --chmod` pins config and script modes
|
||||||
|
in the Dockerfile; `logrotate-loop.sh` refuses a config logrotate would
|
||||||
|
ignore. E2e checks mode `644`, forced rotation, and a group-writable context
|
||||||
|
build.
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-08-12
|
||||||
|
|
||||||
|
Status page layout after 1.2.1: paired cards in a wide column, denser machine
|
||||||
|
and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: **Status** is wide again so paired cards fill the column. Layout:
|
||||||
|
Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖
|
||||||
|
Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS
|
||||||
|
certificate, and Hostname (and Hostname's trailing detail line); milter
|
||||||
|
socket paths omitted from the table; queue link reads **View queue**; milter
|
||||||
|
ok detail is `Listening` without a trailing period and sits in its own
|
||||||
|
Detail column beside the status badge; CPU detail is only core and thread
|
||||||
|
counts (no load average); memory detail is `N used of M` without the
|
||||||
|
«available to new work» clause; network detail lists per-interface totals
|
||||||
|
only (rates stay in the Usage column). No «On this page» section index —
|
||||||
|
the paired layout is short enough. Hostname stays in the polled fragment so
|
||||||
|
the pair survives HTMX refresh. In-panel docs for the removed blurbs filed
|
||||||
|
as roadmap `panel-docs`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
|
||||||
|
shrinks each card to its content and centres it in the track instead of
|
||||||
|
filling half the row. Side margins are cancelled for `.split > .card`
|
||||||
|
(Status, Settings, and a delivery's message/history).
|
||||||
|
- panel: on the user create/edit form, **Assigned domains** checkboxes stacked
|
||||||
|
the box above the domain name (and stretched it full-width) because the form's
|
||||||
|
block-label and full-width input rules applied to them. Checkbox rows now use
|
||||||
|
the shared `label.check` layout; the fieldset has matching spacing.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-08-11
|
||||||
|
|
||||||
|
Panel refinements after 1.2.0: navigation icons, status-badge centreing,
|
||||||
|
drill-down back-link placement, and safeguards for the sole global
|
||||||
|
administrator. Upgrading is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: status badge text sat low in the box (and below the heading or label
|
||||||
|
beside it). IBM Plex Mono sits low in its em square; the previous top-heavy
|
||||||
|
padding made that worse. Bottom padding is now heavier so the word centres
|
||||||
|
optically.
|
||||||
|
- panel: the **Users** navigation icon was two full silhouettes with staggered
|
||||||
|
baselines, so the pair looked lopsided at 16 px. The rear person is now a
|
||||||
|
right-side crescent (head + shoulder) behind a full front silhouette aligned
|
||||||
|
with `icon-account`.
|
||||||
|
- panel: the **Settings** navigation icon was a sun-with-rays (circle plus
|
||||||
|
spokes), not a gear. It now uses a toothed cog so it matches the other
|
||||||
|
session icons and the 1.2.0 release note.
|
||||||
|
- panel: the user create/edit form placed «Back to users» at the bottom of the
|
||||||
|
card instead of under the heading like the delivery, domain, and domain-delete
|
||||||
|
pages. A shared `back_link` template now renders every drill-down up-link, and
|
||||||
|
`TestDrillDownPagesPlaceBackLinkAboveContent` guards its position.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: the user edit form disables role change and delete for the only global
|
||||||
|
administrator, with a short note, instead of allowing the action and showing
|
||||||
|
an error on submit.
|
||||||
|
- panel: the user create/edit form hides **Assigned domains** when the role is
|
||||||
|
global administrator, since that role manages every domain anyway.
|
||||||
|
- panel: **Settings** shows panel credentials and DMARC aggregate reports side
|
||||||
|
by side for global administrators (the same `.split` layout as a delivery's
|
||||||
|
message and history). Domain-scoped users keep the single narrow card.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-08-11
|
||||||
|
|
||||||
|
The second MINOR after 1.0.0: domain administrators with per-domain scope, a
|
||||||
|
panel visual refresh on the SelfPost palette, and refinements to navigation and
|
||||||
|
the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the
|
||||||
|
single administrator becomes a global user.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: **domain-admin role** — global administrators manage panel users and
|
||||||
|
assign domains; domain administrators see only their domains (applications,
|
||||||
|
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
|
||||||
|
backup, mail queue, system log, domain add/delete, and `/reload` stay
|
||||||
|
global-only. SQLite migration `0005_panel_users` migrates the single
|
||||||
|
administrator into a global user; sessions and full backup restore carry users
|
||||||
|
and domain bindings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
|
||||||
|
runtime image can copy it into `/usr/share/doc/selfpost/` as AGPL requires. A
|
||||||
|
clean build failed once the cached layer was invalidated.
|
||||||
|
- panel: on signed-in pages with a narrow card (**Settings**, the user form)
|
||||||
|
the heading, flash, card and footer now share one left edge. `.card.narrow`
|
||||||
|
had overridden only `max-width` while `main > *` still centred siblings on
|
||||||
|
the 48rem measure, so the card floated 12rem to the right of the title.
|
||||||
|
`main:has(> .card.narrow) > *` keeps the stack on 24rem without narrowing
|
||||||
|
the column, so the navigation stays put; login/setup are unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: navigation session icons are distinct — **Settings** uses a gear,
|
||||||
|
the signed-in user line carries the single-user icon, and **Users** a
|
||||||
|
two-person group mark instead of the same account silhouette for all three.
|
||||||
|
- panel: `/` redirects domain administrators to `/domains`; global users still
|
||||||
|
land on `/status`. Navigation hides global-only sections for domain
|
||||||
|
administrators.
|
||||||
|
- panel: **Sign out** in the navigation column uses the same type size and
|
||||||
|
weight as the page entries above it; only the red tint and border mark it as
|
||||||
|
destructive.
|
||||||
|
- panel: **visual style** brought in line with the SelfPost mark — brick accent
|
||||||
|
and warm paper in place of the blue-on-cool-grey defaults, IBM Plex Sans and
|
||||||
|
IBM Plex Mono served by the panel itself, squarer corners, and column
|
||||||
|
headings, status badges and small labels set in the mono face. Light and dark
|
||||||
|
schemes both keep their contrast; no page, control or workflow changed. The
|
||||||
|
send log stops breaking `Details` and `deferred` across two lines when a row
|
||||||
|
is tight. Badge padding and line-height are tuned so lowercase labels sit
|
||||||
|
centred in the box. The three WOFF2 files add ~76 KB to the image and are
|
||||||
|
served from the panel's own origin, so the Content-Security-Policy is
|
||||||
|
unchanged (`default-src 'self'`).
|
||||||
|
- panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
|
||||||
|
colours the status page and the DNS checks use, instead of the one place in
|
||||||
|
the panel where a status was bare text. The mapping is the one the delivery
|
||||||
|
page already applied — `sent` is ok, `deferred` a warning, `bounced` and
|
||||||
|
`rejected` errors, `queued` unknown because nothing has gone wrong yet.
|
||||||
|
- docs: [roadmap.md](docs/roadmap.md) and [product.md](docs/product.md) no
|
||||||
|
longer list domain-admin or visual-style as open work — both ship in this line.
|
||||||
|
Completed plan files (`domain-admin`, `visual-style`, `web-split`,
|
||||||
|
`narrow-page-alignment`) are removed; history stays in git and the entries
|
||||||
|
above. Inbound relay is the main agreed 1.x+ item left on the roadmap.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-10
|
||||||
|
|
||||||
|
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
|
||||||
|
packaging on every page, and an internal split of `internal/web` ahead of
|
||||||
|
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
|
||||||
|
existing DNS guidance is unchanged until you set a report address).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
|
||||||
|
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
|
||||||
|
is unchanged for `cmd/panel`. Templates and static assets moved under
|
||||||
|
`internal/web/view/`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
|
||||||
|
is now `p=none` without `rua=` by default; *Settings* and each domain page
|
||||||
|
let you configure an optional aggregate-report address (profile default plus
|
||||||
|
per-domain inherit / none / custom). When `rua=` targets another domain, the
|
||||||
|
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
|
||||||
|
Domain export/import carries per-domain overrides.
|
||||||
|
- AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
|
||||||
|
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); the
|
||||||
|
panel footer on every page — including login and setup — shows copyright, a
|
||||||
|
link to `/license` (embedded AGPL text), a Source link to the public
|
||||||
|
repository, and "No warranty"; the runtime image ships `LICENSE` and
|
||||||
|
`NOTICE` under `/usr/share/doc/selfpost/`. `docs/development.md` now lists
|
||||||
|
the vendored htmx asset beside the Go module licences.
|
||||||
|
- [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel
|
||||||
|
visual refresh: typography, colour tokens, spacing, and component styling
|
||||||
|
without behaviour changes). Starting reference:
|
||||||
|
[docs/assets/selfpost-proof.html](docs/assets/selfpost-proof.html). No semver
|
||||||
|
impact; explicit agreement required before coding, like other candidates.
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-08-09
|
||||||
|
|
||||||
|
A documentation and packaging release: no change to the mail path, the
|
||||||
|
database, or the on-disk layout. Upgrading is a tag bump.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
|
||||||
|
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
|
||||||
|
fixes, and what is in and out of scope for a relay. No response time is
|
||||||
|
promised. Without it a finder's default move is a public issue, which
|
||||||
|
discloses a relay flaw to everyone the moment it is filed.
|
||||||
|
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
|
||||||
|
inbound relay, the domain-admin role, and splitting the oversized `web`
|
||||||
|
package. Each states scope, open questions, and what has to be true before
|
||||||
|
coding starts. [docs/roadmap.md](docs/roadmap.md) is restructured around them
|
||||||
|
as a 1.x+ tracker instead of a 2.x wishlist, and now says how to read it from
|
||||||
|
outside the project: nothing in it is a commitment, there are no dates, and
|
||||||
|
the stated order is a recommendation.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||||
|
heading, browser title, and the operator guide. 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.
|
||||||
|
- [docs/product.md](docs/product.md) reframes the future line: the inbound
|
||||||
|
relay and the domain-admin role are agreed **1.x+** extensions tracked in the
|
||||||
|
roadmap and the plans, with the inbound relay targeting a MINOR bump by
|
||||||
|
default and a 2.x major still possible pending implementation. Only items the
|
||||||
|
roadmap still marks *candidate* need explicit approval before coding. It
|
||||||
|
previously put the whole line behind a 2.x.x that nothing had committed to.
|
||||||
|
- [docs/security.md](docs/security.md) is now in English, matching the rest of
|
||||||
|
the published docs — it is linked from the README table and from
|
||||||
|
`SECURITY.md`, so a reader following either landed in Russian. Content is
|
||||||
|
unchanged: same requirements, same accepted risks, same ADR. The reviewing
|
||||||
|
model is no longer named in the text; the fact that a pre-release review ran,
|
||||||
|
and its date, stay. The roadmap and the plans are in English for the same
|
||||||
|
reason, and neither records the model assigned to an item any more.
|
||||||
|
- The README documentation table now points at `SECURITY.md` for reporting a
|
||||||
|
vulnerability, and the `docs/security.md` row is renamed *Security design* —
|
||||||
|
with two files a reader could reasonably call "security", the table said
|
||||||
|
which is which only by accident. The roadmap row no longer calls the file
|
||||||
|
internal and Russian, because it is neither. `development.md` lists
|
||||||
|
`SECURITY.md` among the user-facing deliverables.
|
||||||
|
- `docs/development.md` records the decision on authorship: SelfPost is written
|
||||||
|
by AI agents under a maintainer's direction and the project discloses that,
|
||||||
|
so the `Co-Authored-By` trailers, the model routing table, and the agent
|
||||||
|
rules file all stay. Written down to settle the question rather than have it
|
||||||
|
reopened at each release.
|
||||||
|
- The two remaining Russian source comments are in English:
|
||||||
|
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
|
||||||
|
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
|
||||||
|
rather than translated — rendered in English it restated the sentence it was
|
||||||
|
attached to. The Cyrillic that remains is test data, where it is the point.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The panel's static assets are served with a content ETag and
|
||||||
|
`Cache-Control: no-cache`. They are embedded in the binary, so their
|
||||||
|
modification times are the zero value and no `Last-Modified` was sent; with
|
||||||
|
no validator at all the browser was free to guess how long to keep them,
|
||||||
|
which is why a tab kept showing the previous favicon after the new mark
|
||||||
|
shipped. Each asset is now hashed once at startup, so an unchanged one costs
|
||||||
|
a bodyless 304 and a changed one is picked up on the next load. A browser
|
||||||
|
that cached an asset *before* this release still has nothing to revalidate
|
||||||
|
against, so that one copy has to be cleared by hand.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-09
|
## [1.0.0] - 2026-08-09
|
||||||
|
|
||||||
@@ -59,6 +999,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
- 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
|
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).
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
|
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||||
|
`unknown blob` (and similar) errors after layers already uploaded.
|
||||||
- A send-log row could stay `queued` forever after the container was recreated.
|
- 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
|
`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
|
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||||
@@ -368,7 +1310,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
||||||
unrecognised status word, a capitalised one, and a cleanup line.
|
unrecognised status word, a capitalised one, and a cleanup line.
|
||||||
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
||||||
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
|
*Backup and domain export* + accepted risk (encryption is opt-in);
|
||||||
`docs/architecture.md` persistence § envelope summary.
|
`docs/architecture.md` persistence § envelope summary.
|
||||||
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
||||||
release commit (move to `docs/archive/`, retarget its references in README,
|
release commit (move to `docs/archive/`, retarget its references in README,
|
||||||
@@ -523,9 +1465,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
Bookmarks to the old paths stop working.
|
Bookmarks to the old paths stop working.
|
||||||
|
|
||||||
- panel: each entry in the navigation bar now carries an icon beside its label,
|
- 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.
|
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,
|
Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons
|
||||||
no exemption from the panel's Content-Security-Policy — and are hidden from
|
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.
|
screen readers, which still announce the label alone.
|
||||||
|
|
||||||
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
|
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
|
||||||
@@ -665,6 +1608,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
login/password and on the sending server name.
|
login/password and on the sending server name.
|
||||||
- panel: the *Addresses* field is hidden while an application's address mode is
|
- panel: the *Addresses* field is hidden while an application's address mode is
|
||||||
*Any address of the domain*, where the server ignores it.
|
*Any address of the domain*, where the server ignores it.
|
||||||
|
- deploy: `cap_add` gains `CAP_FOWNER` and `CAP_FSETID` so the entrypoint can
|
||||||
|
chmod and set the setgid bit on `/data` directories it has just chowned under
|
||||||
|
`cap_drop: ALL`, and `CAP_KILL` so supervisord can signal OpenDKIM across uids
|
||||||
|
when domains are added or removed. Production boot under the hardened compose
|
||||||
|
file exposed both gaps after Phase 10.
|
||||||
- ci: disable provenance attestation on release image push, so the ghcr.io
|
- ci: disable provenance attestation on release image push, so the ghcr.io
|
||||||
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
|
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
|
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# SelfPost build.
|
# SelfPost build.
|
||||||
#
|
#
|
||||||
# The version is stamped into both binaries and MUST match the Docker image tag
|
# 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:
|
# (architecture.md § Persistence: backup/restore compatibility check). Override
|
||||||
|
# on the command line:
|
||||||
#
|
#
|
||||||
# make build VERSION=1.3.0
|
# make build VERSION=1.3.0
|
||||||
#
|
#
|
||||||
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
|
||||||
# Go, so no cgo is required) — see spec 7.1.
|
# Go, so no cgo is required).
|
||||||
|
|
||||||
VERSION ?= dev
|
VERSION ?= dev
|
||||||
MODULE := github.com/mixeme/selfpost
|
MODULE := github.com/mixeme/selfpost
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
SelfPost
|
||||||
|
Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
|
||||||
|
This product is licensed under the GNU Affero General Public License,
|
||||||
|
version 3. The full licence text is in the LICENSE file in this
|
||||||
|
distribution (and at /license in the control panel).
|
||||||
|
|
||||||
|
Corresponding Source for the published releases is at:
|
||||||
|
https://github.com/mixeme/selfpost
|
||||||
|
|
||||||
|
If you modify SelfPost and let users interact with it over a network,
|
||||||
|
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||||
|
your modified version. Update SourceURL in internal/legal/legal.go
|
||||||
|
so the panel footer points at your sources.
|
||||||
|
|
||||||
|
Third-party software included in this distribution
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||||
|
Copyright (c) Big Sky Software
|
||||||
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
|
IBM Plex Sans / IBM Plex Mono
|
||||||
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
|
Licence: SIL Open Font License 1.1
|
||||||
|
Full text: internal/web/view/static/OFL.txt
|
||||||
|
(also /usr/share/doc/selfpost/OFL.txt in the image, and
|
||||||
|
/static/OFL.txt from the control panel)
|
||||||
|
https://github.com/IBM/plex
|
||||||
|
Distributed unmodified as three latin-subset WOFF2 files served by the
|
||||||
|
control panel:
|
||||||
|
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
|
||||||
|
internal/web/view/static/ibm-plex-mono-400.woff2
|
||||||
|
internal/web/view/static/ibm-plex-mono-600.woff2
|
||||||
|
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
|
||||||
|
the Google Fonts distribution. The wordmarks in the shipped SVG logos
|
||||||
|
are outlined paths derived from the same fonts.
|
||||||
|
|
||||||
|
Go module dependencies are listed with their licences in
|
||||||
|
docs/development.md § External libraries. Debian packages in the
|
||||||
|
runtime image carry their own copyright files as shipped by Debian.
|
||||||
@@ -4,13 +4,16 @@
|
|||||||
|
|
||||||
# SelfPost
|
# SelfPost
|
||||||
|
|
||||||
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
|
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
|
||||||
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
|
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
|
||||||
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
|
configure domains, DKIM keys, and SASL applications once, then point your apps
|
||||||
|
at the SMTP endpoint.
|
||||||
|
|
||||||
|
SelfPost sends mail straight to the internet from **your own IP**, with
|
||||||
|
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
|
||||||
|
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
|
||||||
|
mail.
|
||||||
|
|
||||||
**For:** operators who run their own VPS or home server and want a simple relay
|
**For:** operators who run their own VPS or home server and want a simple relay
|
||||||
they control, without a third-party SMTP provider.
|
they control, without a third-party SMTP provider.
|
||||||
@@ -21,12 +24,16 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
||||||
|
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
|
||||||
|
- Optional DMARC aggregate report ingest on port 25 (off by default)
|
||||||
|
- In-panel Help drawer and `/help` page for Status and domain controls
|
||||||
|
- Per-application client IP allow-list for SMTP AUTH (optional)
|
||||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||||
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
- Full-server backup and single-domain export/import (optional password encryption)
|
- Full-server backup and single-domain export/import (optional password encryption)
|
||||||
- Single Docker image; data in a `./data` bind mount
|
- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -35,11 +42,14 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
| [**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 |
|
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
|
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||||
|
the private reporting channel and the scope.
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
the `ghcr.io/mixeme/selfpost` image.
|
the `ghcr.io/mixeme/selfpost` image.
|
||||||
|
|
||||||
@@ -73,14 +83,24 @@ For every domain you add in the panel:
|
|||||||
- [ ] DKIM TXT record (value shown on the domain page)
|
- [ ] DKIM TXT record (value shown on the domain page)
|
||||||
- [ ] DMARC `_dmarc` TXT record
|
- [ ] DMARC `_dmarc` TXT record
|
||||||
|
|
||||||
See [DNS setup](docs/guide.md#dns-setup) in the operator guide.
|
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
|
||||||
|
|
||||||
|
### Per inbound domain (optional)
|
||||||
|
|
||||||
|
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
|
||||||
|
|
||||||
|
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
|
||||||
|
if this host is backup-MX)
|
||||||
|
|
||||||
|
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
> **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
|
> 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.
|
> the administrator username and password. Until you do, the panel has no login.
|
||||||
> Production deploy: [step 3](#3-start-selfpost).
|
> Production deploy: [Full deployment](docs/guide.md#full-deployment) in the
|
||||||
|
> operator guide.
|
||||||
|
|
||||||
One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
|
||||||
files, no compose files. Good for clicking through the UI on your machine;
|
files, no compose files. Good for clicking through the UI on your machine;
|
||||||
@@ -92,7 +112,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:1.0.0
|
ghcr.io/mixeme/selfpost:1.9.1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -126,139 +146,25 @@ 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
|
only through a reverse proxy on 443 — port 8080 is bound to localhost in the
|
||||||
default compose file.
|
default compose file.
|
||||||
|
|
||||||
| Artefact | Path |
|
Full walkthrough — fetching the base files, setting up a reverse proxy and
|
||||||
|---|---|
|
TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up
|
||||||
| Compose file (fixed image tag) | [deploy/docker-compose.yml](deploy/docker-compose.yml) |
|
DNS — lives in the operator guide's [Full
|
||||||
| Environment template | [deploy/.env.example](deploy/.env.example) |
|
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
||||||
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) |
|
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||||
| nginx | [deploy/nginx/](deploy/nginx/) |
|
|
||||||
| Caddy | [deploy/caddy/](deploy/caddy/) |
|
|
||||||
| Traefik | [deploy/traefik/](deploy/traefik/) |
|
|
||||||
|
|
||||||
### 1. Fetch the base files
|
The compose file always publishes **465**, **587**, and **25**; Postfix listens
|
||||||
|
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
|
||||||
```sh
|
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
|
||||||
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
[Ports](docs/guide.md#ports)). Bump the
|
||||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
|
||||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||||
cp .env.example .env
|
variables](docs/guide.md#environment-variables).
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## License
|
||||||
|
|
||||||
|
Copyright © 2026 Mikhail Yenuchenko.
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
[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
|
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.
|
available to its users — not only when you distribute copies of the code.
|
||||||
|
Third-party notices: [NOTICE](NOTICE).
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||||
|
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||||
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest 1.x | yes |
|
||||||
|
| earlier 1.x | no — upgrade first |
|
||||||
|
| 0.x | no (pre-release) |
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||||
|
the *Report a vulnerability* button under the repository's
|
||||||
|
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||||
|
it, mail `public@mixeme.ru` instead.
|
||||||
|
|
||||||
|
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||||
|
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||||
|
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||||
|
|
||||||
|
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||||
|
deadline that cannot be honoured is worse than none. Reports are read and
|
||||||
|
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||||
|
with the timeline agreed in the thread.
|
||||||
|
|
||||||
|
Disclosure is coordinated by request, not by demand: please hold public details
|
||||||
|
until a patch is out. If you get no reply, that is not a request for a
|
||||||
|
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||||
|
the CHANGELOG unless they ask not to be.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
The relay's job is to accept authenticated mail from an application and hand it
|
||||||
|
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||||
|
scope:
|
||||||
|
|
||||||
|
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||||
|
a domain the sending application is not bound to
|
||||||
|
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||||
|
from anything the container exposes
|
||||||
|
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||||
|
it was not granted
|
||||||
|
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||||
|
tokens, or backup encryption material leaking to an unauthorised party
|
||||||
|
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||||
|
CSRF on state-changing routes, privilege escalation
|
||||||
|
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||||
|
per-domain and per-application limits
|
||||||
|
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||||
|
user to root
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
These are the operator's responsibility or accepted trade-offs, documented in
|
||||||
|
[docs/security.md](docs/security.md) and the
|
||||||
|
[operator guide](docs/guide.md):
|
||||||
|
|
||||||
|
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||||
|
wrong PTR record, DNS records not published, a self-signed or expired
|
||||||
|
certificate on the reverse proxy
|
||||||
|
- Anything requiring the attacker to already have root on the host or write
|
||||||
|
access to the `./data` bind mount
|
||||||
|
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||||
|
never terminates HTTPS itself
|
||||||
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
|
a policy decision of that provider, not a defect
|
||||||
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
## Reports we cannot act on
|
||||||
|
|
||||||
|
Automated scanner output with no demonstrated impact, and reports whose only
|
||||||
|
content is a version number compared against a CVE list, are closed without
|
||||||
|
investigation.
|
||||||
+41
-33
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
|
||||||
# supervisord (spec 4). Build from the repository root:
|
# supervisord. Build from the repository root:
|
||||||
#
|
#
|
||||||
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
# Version stamped into both binaries; MUST match the image tag.
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
# Module metadata first for layer caching.
|
# Module metadata first for layer caching.
|
||||||
@@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
|||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Unprivileged user for the panel process (spec 7.6.8).
|
# Unprivileged user for the panel process.
|
||||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||||
|
|
||||||
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
# Shared group bridging the unprivileged services: the panel generates
|
||||||
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
|
||||||
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
# sender map, while OpenDKIM and Postfix (different users) must read them.
|
||||||
# them. Membership in this group — plus setgid dirs under /data (set up in
|
# 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
|
# 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
|
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
|
||||||
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
# to signal OpenDKIM/Postfix reloads without any process running as root.
|
||||||
@@ -67,15 +67,15 @@ RUN groupadd --system selfpost \
|
|||||||
&& usermod -aG selfpost opendkim \
|
&& usermod -aG selfpost opendkim \
|
||||||
&& usermod -aG selfpost postfix
|
&& usermod -aG selfpost postfix
|
||||||
|
|
||||||
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
# The panel reads the mail queue with `postqueue -p` (and the status page's
|
||||||
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
# queue card). postqueue is setgid postdrop, which normally gives it the group
|
||||||
# it the group needed to reach Postfix's showq socket — but the documented
|
# needed to reach Postfix's showq socket — but the documented deployment runs
|
||||||
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||||
# so the panel would always see "Permission denied". Making `panel` a real
|
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||||
# member of postdrop grants the same access without relying on a setgid
|
# grants the same access without relying on a setgid escalation the hardening
|
||||||
# escalation the hardening deliberately forbids. postdrop membership is
|
# deliberately forbids. postdrop membership is read-side only: it does not let
|
||||||
# read-side only: it does not let the panel bypass any Postfix restriction that
|
# the panel bypass any Postfix restriction that a local user does not already
|
||||||
# a local user does not already have through the world-executable sendmail.
|
# have through the world-executable sendmail.
|
||||||
RUN usermod -aG postdrop panel
|
RUN usermod -aG postdrop panel
|
||||||
|
|
||||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||||
@@ -86,23 +86,31 @@ RUN mkdir -p /run/opendkim /run/selfpost /data \
|
|||||||
COPY --from=build /out/panel /usr/local/bin/panel
|
COPY --from=build /out/panel /usr/local/bin/panel
|
||||||
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||||
|
|
||||||
COPY build/opendkim.conf /etc/opendkim.conf
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
# serves the same text at /license from an embedded copy. OFL.txt is the SIL
|
||||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
|
||||||
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
# embedded in the panel binary (NOTICE).
|
||||||
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
|
||||||
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)
|
# File modes are pinned here so a build context copied from a checkout without
|
||||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||||
# which needs no inbound listener or EXPOSE.
|
# that logrotate would silently ignore — see docs/development.md § Building
|
||||||
EXPOSE 8080 465 587
|
# binaries and the image.
|
||||||
|
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||||
|
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
|
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
|
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||||
|
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||||
|
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||||
|
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||||
|
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
|
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
|
||||||
|
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
|
||||||
|
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
||||||
|
EXPOSE 8080 465 587 25
|
||||||
|
|
||||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
||||||
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||||
@@ -110,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
|||||||
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
|
||||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||||
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
# supervisord, which becomes PID 1 and owns process supervision.
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
If any managed program exhausts its restart retries and enters FATAL, this
|
If any managed program exhausts its restart retries and enters FATAL, this
|
||||||
listener signals supervisord (its parent) to terminate, so the whole container
|
listener signals supervisord (its parent) to terminate, so the whole container
|
||||||
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
exits and Docker's `restart` policy can recreate it cleanly — rather than
|
||||||
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
|
lingering "alive" with a dead Postfix/OpenDKIM/panel.
|
||||||
|
|
||||||
Communication with supervisord uses the event listener protocol over stdin and
|
Communication with supervisord uses the event listener protocol over stdin and
|
||||||
stdout, so stdout must carry only protocol tokens.
|
stdout, so stdout must carry only protocol tokens.
|
||||||
|
|||||||
+43
-29
@@ -9,14 +9,14 @@ set -e
|
|||||||
|
|
||||||
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
# 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
|
# 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
|
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
|
||||||
# postfix-config.sh each fall back independently when it's unset — to
|
# fall back independently when it's unset — to `localhost` and to the container
|
||||||
# `localhost` and to the container hostname respectively — so accounts get
|
# hostname respectively — so accounts get written under one realm and looked up
|
||||||
# written under one realm and looked up under another and authentication
|
# under another and authentication silently fails for every application, while
|
||||||
# silently fails for every application, while HELO also stops matching the
|
# HELO also stops matching the PTR record and mail that does go out lands in
|
||||||
# PTR record and mail that does go out lands in spam. No fallback can be
|
# spam. No fallback can be correct, so fail loudly here, before either side of
|
||||||
# correct, so fail loudly here, before either side of that split has a chance
|
# that split has a chance to run, rather than leave a green panel with broken
|
||||||
# to run, rather than leave a green panel with broken mail.
|
# mail.
|
||||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
FATAL: SELFPOST_HOSTNAME is not set.
|
FATAL: SELFPOST_HOSTNAME is not set.
|
||||||
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
# The persistent root /data is a host bind mount, so it arrives owned by the
|
||||||
# by the host user (typically root), not by the unprivileged panel user that
|
# host user (typically root), not by the unprivileged panel user that actually
|
||||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
|
||||||
# Fix its ownership here — the one place still running as root — before handing
|
# — the one place still running as root — before handing off to supervisord,
|
||||||
# off to supervisord, which starts the panel as the panel user.
|
# which starts the panel as the panel user.
|
||||||
#
|
#
|
||||||
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
# 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
|
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||||
@@ -69,9 +69,9 @@ chmod 755 /data
|
|||||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||||
# normalised on its own below.
|
# normalised on its own below.
|
||||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
|
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||||
|
|
||||||
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
# DKIM key tree. The panel (user `panel`) generates keys and writes
|
||||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
# 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
|
# tree on every start so it is correct whether /data is fresh, restarted, or
|
||||||
# just restored from a backup:
|
# just restored from a backup:
|
||||||
@@ -89,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} +
|
|||||||
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
||||||
|
|
||||||
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
|
# Application SASL accounts. The panel (user `panel`) writes the
|
||||||
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
|
||||||
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
|
# 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
|
# setgid directory so new files inherit the group, and the database itself
|
||||||
@@ -99,13 +99,20 @@ chown -R panel:selfpost /data/sasl
|
|||||||
chmod 2750 /data/sasl
|
chmod 2750 /data/sasl
|
||||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||||
|
|
||||||
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
# Postfix state under /data (architecture.md § Persistence). The panel writes
|
||||||
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||||
# references it never fails on a missing file, and keep it group-readable.
|
mkdir -p /data/postfix/queue
|
||||||
mkdir -p /data/postfix
|
|
||||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||||
chown -R panel:selfpost /data/postfix
|
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
|
||||||
|
# Empty files keep postfix check happy if the flag is on before any domain exists.
|
||||||
|
for f in relay_domains transport relay_recipients tls_policy; do
|
||||||
|
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
|
||||||
|
chown panel:selfpost "/data/postfix/$f"
|
||||||
|
chmod 0640 "/data/postfix/$f"
|
||||||
|
done
|
||||||
|
chown panel:selfpost /data/postfix
|
||||||
chmod 2750 /data/postfix
|
chmod 2750 /data/postfix
|
||||||
|
chown panel:selfpost /data/postfix/sender_login_maps
|
||||||
chmod 0640 /data/postfix/sender_login_maps
|
chmod 0640 /data/postfix/sender_login_maps
|
||||||
|
|
||||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||||
@@ -125,14 +132,14 @@ chown -R postfix:selfpost /data/log
|
|||||||
chmod 2750 /data/log
|
chmod 2750 /data/log
|
||||||
find /data/log -type f -exec chmod 0640 {} +
|
find /data/log -type f -exec chmod 0640 {} +
|
||||||
|
|
||||||
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
|
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
|
||||||
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
|
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
|
||||||
# journal-milter — not just probe them at start-up. The sockets are
|
# probe them at start-up. The sockets are created by the opendkim and panel
|
||||||
# created by the opendkim and panel users respectively, so bridge them to
|
# users respectively, so bridge them to `postfix` through the shared `selfpost`
|
||||||
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
|
# group: group-owned + setgid dirs mean each socket created inside inherits
|
||||||
# each socket created inside inherits group `selfpost`, and group-traversable
|
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
|
||||||
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
|
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
|
||||||
# because signing is strict (default_action=tempfail), rejects all mail.
|
# (default_action=tempfail), rejects all mail.
|
||||||
mkdir -p /run/opendkim /run/selfpost
|
mkdir -p /run/opendkim /run/selfpost
|
||||||
chown opendkim:selfpost /run/opendkim
|
chown opendkim:selfpost /run/opendkim
|
||||||
chown panel:selfpost /run/selfpost
|
chown panel:selfpost /run/selfpost
|
||||||
@@ -144,4 +151,11 @@ chmod 2750 /run/opendkim /run/selfpost
|
|||||||
# start the same way the /data normalisation above is.
|
# start the same way the /data normalisation above is.
|
||||||
/usr/local/bin/postfix-config.sh
|
/usr/local/bin/postfix-config.sh
|
||||||
|
|
||||||
|
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||||
|
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||||
|
if [ ! -d /data/postfix/queue/active ]; then
|
||||||
|
postfix set-permissions
|
||||||
|
fi
|
||||||
|
chown -R postfix:postfix /data/postfix/queue
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
+47
-8
@@ -1,23 +1,62 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
|
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
|
||||||
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
|
# recreates it (`create 0640 postfix selfpost`, matching a cold container
|
||||||
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
|
||||||
# uses): postlogd keeps writing to the renamed inode until reload, and the
|
# 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
|
# panel's log-tailer holds its own descriptor on that inode, so nothing written
|
||||||
# written before the reload is lost. `create` (rather than `nocreate`) matters
|
# before the reload is lost. `create` (rather than `nocreate`) matters here
|
||||||
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
|
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
|
||||||
# owned by postfix, which the unprivileged panel process cannot read —
|
# postfix, which the unprivileged panel process cannot read — confirmed on a
|
||||||
# confirmed on a live container — so logrotate must be the one to create it.
|
# live container — so logrotate must be the one to create it.
|
||||||
#
|
#
|
||||||
# logrotate itself only rotates once the configured "daily" period has elapsed
|
# 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
|
# (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.
|
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
CONFIG=/etc/logrotate.d/mail
|
||||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||||
|
|
||||||
|
# logrotate refuses configs writable by group or others and exits 0 while
|
||||||
|
# ignoring them — fail here so supervisord reports the fault.
|
||||||
|
logrotate_config_ok() {
|
||||||
|
mode=$(stat -c '%a' "$CONFIG")
|
||||||
|
mode=${mode#0}
|
||||||
|
grp=$(( (mode / 10) % 10 ))
|
||||||
|
oth=$(( mode % 10 ))
|
||||||
|
case $grp in 2|3|6|7) return 1 ;; esac
|
||||||
|
case $oth in 2|3|6|7) return 1 ;; esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
logrotate_config_fatal() {
|
||||||
|
echo "logrotate-loop: refusing to run: $CONFIG mode $(stat -c '%a' "$CONFIG") is writable by group or others" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_logrotate() {
|
||||||
|
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||||
|
echo "$out" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
case "$out" in
|
||||||
|
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||||
|
echo "$out" >&2
|
||||||
|
logrotate_config_fatal
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if logrotate /etc/logrotate.d/mail; then
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
if run_logrotate; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
# OpenDKIM — per-domain signing (spec 6).
|
# OpenDKIM — per-domain signing.
|
||||||
#
|
#
|
||||||
# Signing is strictly per-domain: each sending domain has its own key pair and
|
# 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
|
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
|
||||||
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
|
# 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).
|
# supervisord to send this process SIGUSR1 to reload the tables.
|
||||||
#
|
#
|
||||||
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
|
# 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
|
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
|
||||||
|
|||||||
+221
-51
@@ -1,28 +1,59 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
|
# Generate the outbound-relay Postfix configuration.
|
||||||
#
|
#
|
||||||
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
|
# 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
|
# 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
|
# 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
|
# 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
|
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
|
||||||
# is generated separately by the panel — spec 5.1).
|
# is generated separately by the panel).
|
||||||
#
|
#
|
||||||
# main.cf is written with `postconf -e`, the master.cf submission services with
|
# 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
|
# `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).
|
# a fixed literal or a container environment variable.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# --- environment knobs (spec 8) ----------------------------------------------
|
# postconf -P refuses master.cf -o values that contain whitespace; inbound port
|
||||||
|
# 25 needs restriction lists and milter specs that include spaces. Patch the
|
||||||
|
# smtp/inet service stanza directly instead.
|
||||||
|
master_cf_set_smtp_inet_o() {
|
||||||
|
param="$1"
|
||||||
|
value="$2"
|
||||||
|
awk -v p="$param" -v v="$value" '
|
||||||
|
/^smtp[ \t]+inet[ \t]/ && !done {
|
||||||
|
print
|
||||||
|
print " -o " p "=" v
|
||||||
|
done = 1
|
||||||
|
in_smtp_inet = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
in_smtp_inet && /^[ \t]+-o / {
|
||||||
|
line = $0
|
||||||
|
sub(/^[ \t]+-o /, "", line)
|
||||||
|
eq = index(line, "=")
|
||||||
|
on = substr(line, 1, eq - 1)
|
||||||
|
if (on == p) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_smtp_inet && /^[^ \t#-]/ {
|
||||||
|
in_smtp_inet = 0
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' /etc/postfix/master.cf > /etc/postfix/master.cf.new
|
||||||
|
mv /etc/postfix/master.cf.new /etc/postfix/master.cf
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- environment knobs -------------------------------------------------------
|
||||||
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
|
# 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
|
# sasldb2 accounts are looked up under. The panel creates accounts under realm
|
||||||
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
|
||||||
# fails. Fall back to the container hostname only outside a real deployment.
|
# fails. Fall back to the container hostname only outside a real deployment.
|
||||||
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
|
||||||
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
|
||||||
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
# relay requires TLS on 465; if these files are absent the master still starts
|
||||||
# master still starts but TLS handshakes on 465 fail until they appear.
|
# but TLS handshakes on 465 fail until they appear.
|
||||||
#
|
#
|
||||||
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
# 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
|
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||||
@@ -44,8 +75,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
|||||||
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
|
||||||
# defaults, sensible during IP warm-up (spec 10).
|
# during IP warm-up.
|
||||||
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
|
|
||||||
@@ -54,15 +85,41 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
|||||||
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
|
||||||
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
||||||
|
|
||||||
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
# Persistent panel-managed sender map; texthash needs no postmap, so the
|
||||||
# the unprivileged panel can rewrite it and just ask for a reload.
|
# unprivileged panel can rewrite it and just ask for a reload.
|
||||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||||
|
# Transit mail queue under /data so deferred/active messages survive container
|
||||||
|
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||||
|
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
|
||||||
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||||
|
|
||||||
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
# Optional submission service on 587 (off by default, enabled only when a client
|
||||||
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
# library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
||||||
|
|
||||||
|
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
|
||||||
|
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
|
||||||
|
# absent. When true, smtp inet on 25 accepts only relay_domains + known
|
||||||
|
# recipients (docs/plans/inbound-relay.md).
|
||||||
|
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
|
||||||
|
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
|
||||||
|
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||||
|
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||||
|
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
|
||||||
|
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
|
||||||
|
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
|
||||||
|
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
|
||||||
|
|
||||||
|
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
|
||||||
|
# port 25 does not accept report mail and the panel ingest maps stay empty.
|
||||||
|
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
|
||||||
|
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||||
|
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
|
||||||
|
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
|
||||||
|
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
|
||||||
|
|
||||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
# 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
|
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||||
# for messages still marked "queued" survive a container recreate — without
|
# for messages still marked "queued" survive a container recreate — without
|
||||||
@@ -96,13 +153,13 @@ postconf -e \
|
|||||||
"alias_maps=" \
|
"alias_maps=" \
|
||||||
"alias_database="
|
"alias_database="
|
||||||
|
|
||||||
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
|
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtp_tls_security_level=may" \
|
"smtp_tls_security_level=may" \
|
||||||
"smtp_tls_note_starttls_offer=yes"
|
"smtp_tls_note_starttls_offer=yes"
|
||||||
|
|
||||||
# TLS server material shared by every inbound service (spec 5.2). auth_only
|
# TLS server material shared by every inbound service. auth_only guarantees
|
||||||
# guarantees credentials are never accepted before TLS is up on any port.
|
# credentials are never accepted before TLS is up on any port.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_tls_cert_file=${TLS_CERT}" \
|
"smtpd_tls_cert_file=${TLS_CERT}" \
|
||||||
"smtpd_tls_key_file=${TLS_KEY}" \
|
"smtpd_tls_key_file=${TLS_KEY}" \
|
||||||
@@ -110,11 +167,11 @@ postconf -e \
|
|||||||
"smtpd_tls_auth_only=yes" \
|
"smtpd_tls_auth_only=yes" \
|
||||||
"smtpd_tls_loglevel=1"
|
"smtpd_tls_loglevel=1"
|
||||||
|
|
||||||
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
|
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
|
||||||
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
|
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
|
||||||
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
|
# uses for sender_login_maps is the BARE login the panel writes into the map;
|
||||||
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
|
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
|
||||||
# as the server realm, which equals the realm the accounts were created under.
|
# the server realm, which equals the realm the accounts were created under.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sasl_auth_enable=yes" \
|
"smtpd_sasl_auth_enable=yes" \
|
||||||
"smtpd_sasl_type=cyrus" \
|
"smtpd_sasl_type=cyrus" \
|
||||||
@@ -124,42 +181,42 @@ postconf -e \
|
|||||||
"smtpd_sasl_tls_security_options=noanonymous" \
|
"smtpd_sasl_tls_security_options=noanonymous" \
|
||||||
"broken_sasl_auth_clients=yes"
|
"broken_sasl_auth_clients=yes"
|
||||||
|
|
||||||
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
|
# Sender binding (the critical anti-spoofing control). texthash resolves the
|
||||||
# resolves the full address first, then the "@domain" wildcard, so both address
|
# full address first, then the "@domain" wildcard, so both address modes work
|
||||||
# modes work from the same map.
|
# from the same map.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||||
|
"queue_directory=${QUEUE_DIR}"
|
||||||
|
|
||||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
# and every authenticated sender address must be owned by its login. NO
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
# permit_mynetworks anywhere — authorisation is by credentials, never by network
|
# permit_mynetworks anywhere — authorisation is by credentials, never by
|
||||||
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
|
# network. This is what makes an open relay impossible.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_helo_required=yes" \
|
"smtpd_helo_required=yes" \
|
||||||
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
|
||||||
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
|
||||||
|
|
||||||
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
|
# Level-1 rate limit by client IP. Backstop that keeps working even if the
|
||||||
# if the journal-milter (level 2) is down.
|
# journal-milter (level 2) is down.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
|
||||||
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
"anvil_rate_time_unit=${RATE_WINDOW}s"
|
||||||
|
|
||||||
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
|
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
|
||||||
# (default_action=tempfail: if it is unreachable, defer rather than send
|
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
||||||
# unsigned). The journal-milter is monitoring only and is fail-open
|
# monitoring only and is fail-open (default_action=accept): its failure must
|
||||||
# (default_action=accept): its failure must never block the relay. Per-milter
|
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
||||||
# settings use Postfix 3.0+ brace syntax.
|
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"milter_protocol=6" \
|
"milter_protocol=6" \
|
||||||
"milter_default_action=tempfail" \
|
"milter_default_action=tempfail" \
|
||||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||||
"non_smtpd_milters="
|
"non_smtpd_milters="
|
||||||
|
|
||||||
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
|
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
||||||
# replies) must fail open just like a crash, not stall mail acceptance until the
|
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||||
# Postfix defaults (300s content) elapse. With default_action per milter, a
|
# defaults (300s content) elapse. With default_action per milter, a
|
||||||
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
|
||||||
# but within seconds rather than minutes. Values are well above any healthy
|
# but within seconds rather than minutes. Values are well above any healthy
|
||||||
# response time (signing/DB insert are sub-second), so they never fire in normal
|
# response time (signing/DB insert are sub-second), so they never fire in normal
|
||||||
@@ -170,8 +227,8 @@ postconf -e \
|
|||||||
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
|
||||||
|
|
||||||
# --- master.cf: inbound submission services ----------------------------------
|
# --- master.cf: inbound submission services ----------------------------------
|
||||||
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
|
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
|
||||||
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
|
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
|
||||||
# and the Cyrus config outside any chroot.
|
# and the Cyrus config outside any chroot.
|
||||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
@@ -179,10 +236,10 @@ postconf -P \
|
|||||||
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
"smtps/inet/smtpd_sasl_auth_enable=yes" \
|
||||||
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
|
||||||
|
|
||||||
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
|
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
|
||||||
# the only difference is TLS is negotiated via STARTTLS, so require encryption
|
# difference is TLS is negotiated via STARTTLS, so require encryption before
|
||||||
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
|
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
|
||||||
# restart after disabling it does not leave the port listening.
|
# after disabling it does not leave the port listening.
|
||||||
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
|
||||||
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
postconf -M "submission/inet=submission inet n - n - - smtpd"
|
||||||
postconf -P \
|
postconf -P \
|
||||||
@@ -193,18 +250,131 @@ else
|
|||||||
postconf -MX "submission/inet" 2>/dev/null || true
|
postconf -MX "submission/inet" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
|
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||||
# agent and others chrooted to /var/spool/postfix, where they cannot read
|
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||||
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||||
# never leaves. Inside a container the chroot buys little (the container is the
|
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||||
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
|
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||||
# uniformly. Our own smtps/submission services are already n; this covers the
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
# delivery agents and the rest.
|
RELAY_DOMAINS_SETTING=""
|
||||||
|
TRANSPORT_SETTING=""
|
||||||
|
RECIPIENT_RESTRICTIONS="reject"
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||||
|
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||||
|
[ -e "$f" ] || : > "$f"
|
||||||
|
done
|
||||||
|
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||||
|
RECIPIENT_RESTRICTIONS="reject_unauth_destination,reject_unlisted_recipient"
|
||||||
|
fi
|
||||||
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||||
|
[ -e "$f" ] || : > "$f"
|
||||||
|
done
|
||||||
|
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
|
||||||
|
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
|
||||||
|
postconf -P \
|
||||||
|
"dmarc-ingest/unix-pipe/flags=Rq" \
|
||||||
|
"dmarc-ingest/unix-pipe/user=panel" \
|
||||||
|
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest"
|
||||||
|
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||||
|
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
|
else
|
||||||
|
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
|
fi
|
||||||
|
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||||
|
fi
|
||||||
|
postconf -e \
|
||||||
|
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||||
|
"transport_maps=${TRANSPORT_SETTING}" \
|
||||||
|
"smtpd_reject_unlisted_recipient=yes"
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||||
|
postconf -e \
|
||||||
|
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
||||||
|
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||||
|
else
|
||||||
|
postconf -e \
|
||||||
|
"relay_recipient_maps=" \
|
||||||
|
"smtp_tls_policy_maps="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is
|
||||||
|
# for the SUBMISSION path only: OpenDKIM must not gate someone else's
|
||||||
|
# inbound mail (a signing outage would defer it), and the journal-milter
|
||||||
|
# would file inbound mail into the outbound send log under the *sender's*
|
||||||
|
# domain — polluting Deliveries and letting a forged From: consume that
|
||||||
|
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
|
||||||
|
# here: either the optional antispam milter, or nothing at all.
|
||||||
|
INBOUND_MILTERS=""
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||||
|
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||||
|
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
|
||||||
|
accept|tempfail) ;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||||
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||||
|
fi
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
|
||||||
|
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||||
|
else
|
||||||
|
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||||
|
postconf -P \
|
||||||
|
"smtp/inet/smtpd_sasl_auth_enable=no" \
|
||||||
|
"smtp/inet/smtpd_tls_auth_only=no" \
|
||||||
|
"smtp/inet/smtpd_sender_login_maps=" \
|
||||||
|
"smtp/inet/smtpd_sender_restrictions=" \
|
||||||
|
"smtp/inet/smtpd_client_restrictions=" \
|
||||||
|
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
|
||||||
|
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
|
||||||
|
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||||
|
master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
|
||||||
|
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
|
||||||
|
else
|
||||||
|
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||||
|
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true
|
||||||
|
postconf -e \
|
||||||
|
"relay_domains=" \
|
||||||
|
"transport_maps=" \
|
||||||
|
"relay_recipient_maps=" \
|
||||||
|
"smtp_tls_policy_maps="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable chroot for every service. Debian ships the smtp delivery agent and
|
||||||
|
# others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf
|
||||||
|
# — so outbound MX lookups fail with "Host not found" and mail never leaves.
|
||||||
|
# Inside a container the chroot buys little (the container is the isolation
|
||||||
|
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
|
||||||
|
# own smtps/submission services are already n; this covers the delivery agents
|
||||||
|
# and the rest.
|
||||||
postconf -F "*/*/chroot=n"
|
postconf -F "*/*/chroot=n"
|
||||||
|
|
||||||
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
# --- Cyrus SASL app config for smtpd -----------------------------------------
|
||||||
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
|
||||||
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
|
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
|
||||||
# only — both are safe because TLS is mandatory before auth on every port.
|
# only — both are safe because TLS is mandatory before auth on every port.
|
||||||
mkdir -p /etc/postfix/sasl
|
mkdir -p /etc/postfix/sasl
|
||||||
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
cat > /etc/postfix/sasl/smtpd.conf <<EOF
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
|
# Postfix start wrapper: solves the cold-start race where Postfix would try to
|
||||||
# try to reach the milter sockets before they are listening.
|
# reach the milter sockets before they are listening.
|
||||||
#
|
#
|
||||||
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
|
# 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
|
# — are present, then execs `postfix start-fg`. If they are not ready within the
|
||||||
|
|||||||
+17
-16
@@ -1,4 +1,4 @@
|
|||||||
; SelfPost process supervision (spec 4).
|
; SelfPost process supervision.
|
||||||
;
|
;
|
||||||
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
|
||||||
; the journal-milter socket), then the Postfix wrapper — which additionally
|
; the journal-milter socket), then the Postfix wrapper — which additionally
|
||||||
@@ -18,8 +18,8 @@ loglevel=info
|
|||||||
|
|
||||||
; The control socket is reachable by the shared `selfpost` group so the panel
|
; The control socket is reachable by the shared `selfpost` group so the panel
|
||||||
; (running as the unprivileged `panel` user) can ask supervisord to send
|
; (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
|
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
|
||||||
; still unreachable by world; only root and the two SelfPost services are in the
|
; 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
|
; group. No HTTP auth is configured because access is gated by socket
|
||||||
; permissions, not the network.
|
; permissions, not the network.
|
||||||
[unix_http_server]
|
[unix_http_server]
|
||||||
@@ -67,15 +67,15 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
|
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
|
||||||
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
|
; reload` itself, and it cannot signal the Postfix master directly: `postfix
|
||||||
; directly: `postfix start-fg` forks a separate master process, so a signal to
|
; start-fg` forks a separate master process, so a signal to the supervised
|
||||||
; the supervised foreground process never reaches it (unlike OpenDKIM, which
|
; foreground process never reaches it (unlike OpenDKIM, which runs in the
|
||||||
; runs in the foreground as the supervised process itself). Instead the panel
|
; foreground as the supervised process itself). Instead the panel asks
|
||||||
; asks supervisord — over the group-accessible control socket — to run this
|
; supervisord — over the group-accessible control socket — to run this program,
|
||||||
; program, which executes the canonical `postfix reload` as root. autostart is
|
; which executes the canonical `postfix reload` as root. autostart is off (it
|
||||||
; off (it only runs on demand) and a fast, clean exit is expected, so it never
|
; only runs on demand) and a fast, clean exit is expected, so it never trips the
|
||||||
; trips the crashexit listener.
|
; crashexit listener.
|
||||||
[program:postfix-reload]
|
[program:postfix-reload]
|
||||||
command=/usr/sbin/postfix reload
|
command=/usr/sbin/postfix reload
|
||||||
autostart=false
|
autostart=false
|
||||||
@@ -88,7 +88,7 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
|
; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
|
||||||
; so certificates the reverse-proxy renews in the read-only mount are picked up.
|
; 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
|
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
|
||||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
||||||
@@ -103,9 +103,10 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
|
||||||
; in the image). Runs as root so logrotate can read/rotate the log; never exits
|
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
|
||||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
; the config is group/other-writable (logrotate would ignore it silently);
|
||||||
|
; autorestart surfaces BACKOFF on the Status page.
|
||||||
[program:logrotate]
|
[program:logrotate]
|
||||||
command=/usr/local/bin/logrotate-loop.sh
|
command=/usr/local/bin/logrotate-loop.sh
|
||||||
priority=400
|
priority=400
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxIngestStdin = 10 << 20 // 10 MiB
|
||||||
|
|
||||||
|
func runDMARCIngestMode() error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.dmarcEnabled {
|
||||||
|
return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true")
|
||||||
|
}
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT")))
|
||||||
|
if recipient == "" {
|
||||||
|
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
|
||||||
|
}
|
||||||
|
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
|
||||||
|
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||||
|
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDMARCIngestInvocation() bool {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return filepath.Base(os.Args[0]) == "dmarc-ingest"
|
||||||
|
}
|
||||||
@@ -15,9 +15,17 @@ var documentedPublic = []string{
|
|||||||
"PANEL_SESSION_IDLE_DAYS",
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
"SELFPOST_DNS_RESOLVERS",
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
"TRUSTED_PROXY_CIDR",
|
"TRUSTED_PROXY_CIDR",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
}
|
}
|
||||||
|
|
||||||
// documentedInternal matches guide.md "Internal variables (not part of the operator interface)".
|
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||||
var documentedInternal = []string{
|
var documentedInternal = []string{
|
||||||
"SELFPOST_DATA_DIR",
|
"SELFPOST_DATA_DIR",
|
||||||
"SELFPOST_DB_PATH",
|
"SELFPOST_DB_PATH",
|
||||||
@@ -33,6 +41,15 @@ var documentedInternal = []string{
|
|||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
"MILTER_CONNECT_TIMEOUT",
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
@@ -70,6 +87,9 @@ var loadConfigKeys = []string{
|
|||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||||
@@ -84,8 +104,24 @@ var buildScriptKeys = []string{
|
|||||||
"JOURNAL_MILTER_SOCKET",
|
"JOURNAL_MILTER_SOCKET",
|
||||||
"MAIL_LOG",
|
"MAIL_LOG",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SUBMISSION_ENABLE",
|
"SUBMISSION_ENABLE",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
"MILTER_CONNECT_TIMEOUT",
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
|||||||
+90
-10
@@ -3,35 +3,102 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/app"
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/inbound"
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web"
|
"github.com/mixeme/selfpost/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
// mailStack is the panel's domain and application services plus the on-disk
|
||||||
// the database handle shared by all roles: setup, login and the authenticated
|
// mail-path adapters they write through.
|
||||||
// panel surface (security.md).
|
type mailStack struct {
|
||||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
Domains *domain.Service
|
||||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
Apps *app.Service
|
||||||
// service delegates to them when a domain (and its applications) is deleted.
|
Inbound *inbound.Service
|
||||||
pf := postfix.New(cfg.postfixDir)
|
DMARC *dmarc.Service
|
||||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
pf *postfix.Postfix
|
||||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
odk *domain.OpenDKIM
|
||||||
|
inboundEnabled bool
|
||||||
|
dmarcEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||||
|
pf := postfix.New(cfg.postfixDir)
|
||||||
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||||
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||||
|
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||||
|
inb := inbound.NewService(st, pf)
|
||||||
|
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||||
|
return &mailStack{
|
||||||
|
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||||
|
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||||
|
// reloads both daemons — the same work as the Status page's Reload button.
|
||||||
|
func (m *mailStack) Resync() error {
|
||||||
|
if err := m.Domains.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("opendkim resync: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.Apps.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("postfix resync: %w", err)
|
||||||
|
}
|
||||||
|
if m.inboundEnabled {
|
||||||
|
if err := m.Inbound.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("inbound maps resync: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.dmarcEnabled {
|
||||||
|
if err := m.DMARC.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("dmarc maps resync: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mailStack) skipReloadForTest() {
|
||||||
|
m.pf.SetReloadHook(func() error { return nil })
|
||||||
|
m.odk.SetReloadHook(func() error { return nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
// resyncAfterRestore runs one mail-path Resync on the first boot after a
|
||||||
|
// backup restore. testNoReload skips the supervisord reload step so restore
|
||||||
|
// tests can verify file regeneration without a running mail stack.
|
||||||
|
func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
if testNoReload {
|
||||||
|
ms.skipReloadForTest()
|
||||||
|
}
|
||||||
|
return ms.Resync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPanel wires the panel's services over the shared database handle and
|
||||||
|
// builds the HTTP application from cfg. It is the composition of the panel as
|
||||||
|
// the environment describes it, with nothing bound to a port yet.
|
||||||
|
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||||
|
// effective config, including a manual override; the panel keeps this
|
||||||
|
// snapshot for the process lifetime (architecture.md).
|
||||||
|
retryPolicy := postfix.LoadRetryPolicy()
|
||||||
|
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
MailLogPath: cfg.mailLog,
|
MailLogPath: cfg.mailLog,
|
||||||
DataDir: cfg.dataDir,
|
DataDir: cfg.dataDir,
|
||||||
DBPath: cfg.dbPath,
|
DBPath: cfg.dbPath,
|
||||||
|
DeployRoot: cfg.deployRoot,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
@@ -39,7 +106,20 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
JournalSocket: cfg.journalSocket,
|
JournalSocket: cfg.journalSocket,
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
DNSResolvers: cfg.dnsResolvers,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
|
RetryPolicy: retryPolicy,
|
||||||
|
InboundEnabled: cfg.inboundEnabled,
|
||||||
|
DMARCEnabled: cfg.dmarcEnabled,
|
||||||
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||||
|
// the database handle shared by all roles: setup, login and the authenticated
|
||||||
|
// panel surface (security.md).
|
||||||
|
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||||
|
srvApp, err := newPanel(cfg, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-3
@@ -2,6 +2,8 @@
|
|||||||
// several roles (architecture.md § Image and processes) as a supervised
|
// several roles (architecture.md § Image and processes) as a supervised
|
||||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||||
// the rate-limit checks.
|
// the rate-limit checks.
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,6 +28,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if isDMARCIngestInvocation() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||||
|
log.SetPrefix("dmarc-ingest: ")
|
||||||
|
if err := runDMARCIngestMode(); err != nil {
|
||||||
|
log.Printf("ingest failed: %v", err)
|
||||||
|
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
showVersion := flag.Bool("version", false, "print version and exit")
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -60,6 +72,8 @@ type config struct {
|
|||||||
trustedProxies []*net.IPNet
|
trustedProxies []*net.IPNet
|
||||||
sessionIdleDays int
|
sessionIdleDays int
|
||||||
dnsResolvers []string
|
dnsResolvers []string
|
||||||
|
rateLimitMessagesPerIP int
|
||||||
|
rateLimitWindowSeconds int
|
||||||
|
|
||||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
// serves and the two milter sockets it connects to. The defaults mirror
|
// serves and the two milter sockets it connects to. The defaults mirror
|
||||||
@@ -74,6 +88,10 @@ type config struct {
|
|||||||
saslDBPath string
|
saslDBPath string
|
||||||
saslRealm string
|
saslRealm string
|
||||||
postfixDir string
|
postfixDir string
|
||||||
|
deployRoot string
|
||||||
|
|
||||||
|
inboundEnabled bool
|
||||||
|
dmarcEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
@@ -114,6 +132,10 @@ func loadConfig() config {
|
|||||||
// means dnscheck's public defaults; a closed network names its own here.
|
// means dnscheck's public defaults; a closed network names its own here.
|
||||||
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||||
|
|
||||||
|
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
||||||
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||||
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||||
|
|
||||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||||
|
|
||||||
@@ -130,6 +152,11 @@ func loadConfig() config {
|
|||||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||||
saslRealm: saslRealm(),
|
saslRealm: saslRealm(),
|
||||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||||
|
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
|
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||||
|
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
||||||
|
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||||
|
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +241,8 @@ func run() error {
|
|||||||
// touch the database, so schema/format skew between versions cannot corrupt
|
// touch the database, so schema/format skew between versions cannot corrupt
|
||||||
// the restored state. A match consumes the manifest; its absence is the
|
// the restored state. A match consumes the manifest; its absence is the
|
||||||
// normal (non-restore) case.
|
// normal (non-restore) case.
|
||||||
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil {
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +255,25 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
|
if restored {
|
||||||
|
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
|
||||||
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if cfg.dmarcEnabled {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
ms.pf.SetReloadHook(func() error { return nil })
|
||||||
|
if err := ms.DMARC.Resync(); err != nil {
|
||||||
|
log.Printf("dmarc maps bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errc := make(chan error, 3)
|
errc := make(chan error, 4)
|
||||||
|
|
||||||
roles := []struct {
|
roles := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -236,7 +281,20 @@ func run() error {
|
|||||||
}{
|
}{
|
||||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
{"log-tailer", func(ctx context.Context) error {
|
||||||
|
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||||
|
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("send-log retention: %v", err)
|
||||||
|
return cfg.retentionDays
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||||
|
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/logtail"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runAutoRateLimitRecalc recomputes auto-mode level-2 limits on the same
|
||||||
|
// interval as send-log retention pruning (6 hours).
|
||||||
|
func runAutoRateLimitRecalc(ctx context.Context, cfg config, st *store.Store) {
|
||||||
|
recalc := func() {
|
||||||
|
retention := cfg.retentionDays
|
||||||
|
if days, err := st.GetSendLogRetentionDays(cfg.retentionDays); err == nil {
|
||||||
|
retention = days
|
||||||
|
}
|
||||||
|
l1Max := cfg.rateLimitMessagesPerIP
|
||||||
|
if l1Max <= 0 {
|
||||||
|
l1Max = 100
|
||||||
|
}
|
||||||
|
l1Window := cfg.rateLimitWindowSeconds
|
||||||
|
if l1Window <= 0 {
|
||||||
|
l1Window = 3600
|
||||||
|
}
|
||||||
|
n, err := st.RecalcAllAutoRateLimits(retention, l1Max, l1Window)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("auto rate-limit recalc: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("auto rate-limit recalc: updated %d limit(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recalc()
|
||||||
|
t := time.NewTicker(logtail.RetentionInterval())
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
recalc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
||||||
|
// extracts the archive into a project directory and starts the image, and the
|
||||||
|
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
||||||
|
// below stubs that story out — the archive is downloaded from a running panel
|
||||||
|
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||||
|
// is started on the result through the same startup sequence run() uses:
|
||||||
|
// CheckRestore, store.Open, newPanel, Start.
|
||||||
|
|
||||||
|
const (
|
||||||
|
restorePassword = "correct-horse-battery"
|
||||||
|
restoreDomain = "bs.example.ru"
|
||||||
|
restoreSubject = "Order confirmation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restored is the outcome of a full backup-and-restore round trip.
|
||||||
|
type restored struct {
|
||||||
|
panel http.Handler // panel booted on the restored data directory
|
||||||
|
deployRoot string // the restored project directory
|
||||||
|
dataDir string // the restored /data
|
||||||
|
session *http.Cookie // a session opened before the backup was taken
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||||
|
// has been in use, sign in, download a backup from it, extract that archive
|
||||||
|
// into an empty directory and boot a second panel there. A non-empty password
|
||||||
|
// takes the encrypted download and decrypts it on the way in, which is what an
|
||||||
|
// operator does with a .spbk file.
|
||||||
|
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
live := seedPanelProject(t)
|
||||||
|
panel := bootPanel(t, live)
|
||||||
|
session := signIn(t, panel)
|
||||||
|
archive := downloadBackup(t, panel, session, password)
|
||||||
|
|
||||||
|
target := t.TempDir()
|
||||||
|
extract(t, archive, target)
|
||||||
|
|
||||||
|
return restored{
|
||||||
|
panel: bootPanel(t, target),
|
||||||
|
deployRoot: target,
|
||||||
|
dataDir: filepath.Join(target, "data"),
|
||||||
|
session: session,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel has to come up on the restored directory and show the state that
|
||||||
|
// was in the archive, without the operator touching anything else: the domain
|
||||||
|
// and its journal are in the database the archive carried, and the credentials
|
||||||
|
// that worked before the restore still work after it.
|
||||||
|
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
|
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive carries the daemon files, and the first boot after restore
|
||||||
|
// re-derives the maps from SQLite so they stay aligned with the database.
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
|
} {
|
||||||
|
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("the restored data directory has no %s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("%s = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
|
||||||
|
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
|
||||||
|
t.Errorf("the restored project directory has no %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-time setup link is closed by the presence of a panel user, and the
|
||||||
|
// restored database has one. A restore that reopened it would publish a link
|
||||||
|
// that creates a second global administrator on a server holding live mail
|
||||||
|
// credentials (security.md).
|
||||||
|
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
|
||||||
|
}
|
||||||
|
body := getPage(t, r.panel, "/login", nil)
|
||||||
|
if strings.Contains(body, "No administrator has been created yet") {
|
||||||
|
t.Errorf("the restored panel offers first-run setup:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions live in the database, so they travel in the archive: a cookie that
|
||||||
|
// was valid when the backup was taken is valid again on the restored panel.
|
||||||
|
// That is the documented consequence of restoring an older backup (guide §
|
||||||
|
// Backup and restore) — stated here so it cannot change by accident.
|
||||||
|
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "")
|
||||||
|
|
||||||
|
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An encrypted download is the same archive inside an envelope, so it restores
|
||||||
|
// the same way once the password is supplied. The archive is never written to
|
||||||
|
// disk in the clear by the panel, so this is the only place the two paths can
|
||||||
|
// be shown to agree.
|
||||||
|
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||||
|
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
||||||
|
|
||||||
|
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
|
||||||
|
if !strings.Contains(body, restoreSubject) {
|
||||||
|
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
||||||
|
// drifted from the database, that step puts it back before mail flows.
|
||||||
|
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||||
|
deployRoot := seedPanelProject(t)
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
|
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||||
|
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||||
|
t.Fatalf("write stale map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := json.Marshal(backup.Manifest{
|
||||||
|
Format: backup.FormatFull,
|
||||||
|
Version: buildinfo.Version,
|
||||||
|
CreatedAt: "2026-08-14T00:00:00Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckRestore: %v", err)
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
t.Fatal("CheckRestore did not report a restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(mapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sender map: %v", err)
|
||||||
|
}
|
||||||
|
want := "@" + restoreDomain + " shop\n"
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("sender map = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The version guard is what stops a restore from being silently corrupted by
|
||||||
|
// schema skew, and it runs before anything opens the database. The manifest
|
||||||
|
// stays put on a mismatch: the operator's next move is to start the image the
|
||||||
|
// backup names, and it has to be there when they do.
|
||||||
|
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||||
|
deployRoot := seedPanelProject(t)
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
var archive bytes.Buffer
|
||||||
|
if err := backup.Create(&archive, backup.Params{
|
||||||
|
DataDir: dataDir,
|
||||||
|
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||||
|
Version: "9.9.9",
|
||||||
|
DeployRoot: deployRoot,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create backup: %v", err)
|
||||||
|
}
|
||||||
|
target := t.TempDir()
|
||||||
|
extract(t, archive.Bytes(), target)
|
||||||
|
|
||||||
|
cfg := panelConfig(t, target)
|
||||||
|
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the panel booted on a data directory left by another version")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"9.9.9", buildinfo.Version} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
|
||||||
|
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
||||||
|
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
||||||
|
func seedPanelProject(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
deployRoot := t.TempDir()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
dom, err := st.AddDomain(restoreDomain, "selfpost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatalf("add application: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.InsertQueued(store.SendLogEntry{
|
||||||
|
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
|
||||||
|
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert send-log row: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("close store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range map[string]string{
|
||||||
|
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
|
||||||
|
filepath.Join("sasl", "sasldb2"): "SASLDB",
|
||||||
|
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
|
||||||
|
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
|
||||||
|
} {
|
||||||
|
full := filepath.Join(dataDir, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", full, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", full, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
return deployRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDeployFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelConfig resolves the panel's own configuration for a project directory, so
|
||||||
|
// the test finds the files where the running binary would look for them rather
|
||||||
|
// than where it put them. Cookies are marked insecure for the same reason the
|
||||||
|
// e2e stand does it: the test client speaks plain HTTP.
|
||||||
|
func panelConfig(t *testing.T, deployRoot string) config {
|
||||||
|
t.Helper()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
|
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||||
|
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||||
|
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||||
|
// directory; without this the panel would read the host's /data.
|
||||||
|
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
|
||||||
|
return loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||||
|
// and returns the panel's HTTP handler.
|
||||||
|
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||||
|
}
|
||||||
|
if restored {
|
||||||
|
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||||
|
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the restored database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
if restored {
|
||||||
|
if err := resyncAfterRestore(cfg, st, true); err != nil {
|
||||||
|
t.Fatalf("resync after restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel, err := newPanel(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build the panel: %v", err)
|
||||||
|
}
|
||||||
|
if err := panel.Start(); err != nil {
|
||||||
|
t.Fatalf("start the panel: %v", err)
|
||||||
|
}
|
||||||
|
return panel.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// signIn signs in as the seeded administrator and returns the session cookie.
|
||||||
|
func signIn(t *testing.T, h http.Handler) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
|
||||||
|
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("sign in issued no session cookie")
|
||||||
|
}
|
||||||
|
return cookies[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadBackup takes a backup through the panel's own /backup route, the way
|
||||||
|
// the operator does. An empty password downloads the plain archive; otherwise
|
||||||
|
// the response is a .spbk envelope, which is decrypted here.
|
||||||
|
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{}
|
||||||
|
if password != "" {
|
||||||
|
form.Set("encrypt", "1")
|
||||||
|
form.Set("password", password)
|
||||||
|
form.Set("password_confirm", password)
|
||||||
|
}
|
||||||
|
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.Bytes()
|
||||||
|
if password == "" {
|
||||||
|
if secretfile.HasMagic(body) {
|
||||||
|
t.Fatal("an unencrypted download came back as an envelope")
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if !secretfile.HasMagic(body) {
|
||||||
|
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
|
||||||
|
}
|
||||||
|
r, err := secretfile.NewReader(bytes.NewReader(body), password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open the encrypted backup: %v", err)
|
||||||
|
}
|
||||||
|
plain, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt the backup: %v", err)
|
||||||
|
}
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||||
|
// directory before the image is started.
|
||||||
|
func extract(t *testing.T, archive []byte, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the download is not a gzip stream: %v", err)
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the archive: %v", err)
|
||||||
|
}
|
||||||
|
name := filepath.Clean(filepath.FromSlash(hdr.Name))
|
||||||
|
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
||||||
|
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
switch hdr.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
|
f.Close()
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("close %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPage performs a GET and returns the body, failing on any non-200.
|
||||||
|
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := request(t, h, http.MethodGet, target, nil, session)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// request drives the panel's real handler chain, including the origin check,
|
||||||
|
// with the headers a browser on the panel's own page would send.
|
||||||
|
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
|
||||||
|
req.Host = "mail.example.ru"
|
||||||
|
if method == http.MethodPost {
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
}
|
||||||
|
if session != nil {
|
||||||
|
req.AddCookie(session)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
//
|
//
|
||||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
//
|
//
|
||||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
// Use -o to write to a file instead. The resulting archive is self-contained:
|
||||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
||||||
// (architecture.md § Persistence).
|
// secret (architecture.md § Persistence).
|
||||||
//
|
//
|
||||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
// 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
|
// argument, which would show up in the process list) the archive is written as
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
// the same password:
|
// the same password:
|
||||||
//
|
//
|
||||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -88,6 +90,10 @@ func run(outPath, password string) error {
|
|||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
DBPath: dbPath,
|
DBPath: dbPath,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
|
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
|
OnWarn: func(msg string) {
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
// seedProject builds the minimum operator project tree a backup can be taken from.
|
||||||
func seedDataDir(t *testing.T) string {
|
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open store: %v", err)
|
t.Fatalf("open store: %v", err)
|
||||||
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
|
|||||||
if err := st.Close(); err != nil {
|
if err := st.Close(); err != nil {
|
||||||
t.Fatalf("close store: %v", err)
|
t.Fatalf("close store: %v", err)
|
||||||
}
|
}
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
return dataDir
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
|
return deployRoot, dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An encrypted backup is only worth having if the container it came from can
|
// 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
|
// 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.
|
// the CLI are tested as the one round trip an operator actually performs.
|
||||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||||
seedDataDir(t)
|
seedProject(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
encrypted := filepath.Join(dir, "backup.spbk")
|
encrypted := filepath.Join(dir, "backup.spbk")
|
||||||
plain := filepath.Join(dir, "backup.tar.gz")
|
plain := filepath.Join(dir, "backup.tar.gz")
|
||||||
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
names[hdr.Name] = true
|
names[hdr.Name] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
for _, want := range []string{
|
||||||
|
backup.DataArchivePrefix + backup.ManifestName,
|
||||||
|
backup.DataArchivePrefix + "selfpost.db",
|
||||||
|
backup.ComposeFileName,
|
||||||
|
backup.EnvFileName,
|
||||||
|
} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||||
}
|
}
|
||||||
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
// Without a password the CLI keeps producing the plain archive that existing
|
// Without a password the CLI keeps producing the plain archive that existing
|
||||||
// backup scripts consume.
|
// backup scripts consume.
|
||||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||||
seedDataDir(t)
|
seedProject(t)
|
||||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
if err := run(out, ""); err != nil {
|
if err := run(out, ""); err != nil {
|
||||||
t.Fatalf("create backup: %v", err)
|
t.Fatalf("create backup: %v", err)
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ SELFPOST_HOSTNAME=mail.example.com
|
|||||||
# primary 465/smtps listener.
|
# primary 465/smtps listener.
|
||||||
SUBMISSION_ENABLE=false
|
SUBMISSION_ENABLE=false
|
||||||
|
|
||||||
|
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
|
||||||
|
# mail on port 25 only for domains configured in the panel and forwards them
|
||||||
|
# to the upstream you set. Off by default — the outbound path is unchanged.
|
||||||
|
INBOUND_RELAY_ENABLE=false
|
||||||
|
|
||||||
|
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
|
||||||
|
# 25 only for report addresses configured in the panel (global default and/or
|
||||||
|
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
|
||||||
|
DMARC_REPORTS_ENABLE=false
|
||||||
|
|
||||||
|
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
|
||||||
|
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||||
|
# DMARC_MESSAGE_SIZE_LIMIT=5242880
|
||||||
|
|
||||||
|
# Optional milter on the inbound listener only (not 465/587). Empty = off.
|
||||||
|
# Example with deploy/antispam/docker-compose.antispam.yml:
|
||||||
|
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||||
|
# INBOUND_ANTISPAM_MILTER_ACTION=accept
|
||||||
|
|
||||||
|
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
|
||||||
|
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||||
|
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
|
||||||
|
|
||||||
|
|
||||||
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
RATE_LIMIT_WINDOW_SECONDS=3600
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
|
||||||
|
#
|
||||||
|
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
|
||||||
|
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
|
||||||
|
# only when you want filtering on port 25:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
|
||||||
|
#
|
||||||
|
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
|
||||||
|
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
|
||||||
|
#
|
||||||
|
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
|
||||||
|
# deliberately. Default milter action is fail-open (accept) so a down sidecar
|
||||||
|
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||||
|
# defer instead.
|
||||||
|
|
||||||
|
services:
|
||||||
|
selfpost:
|
||||||
|
environment:
|
||||||
|
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
|
||||||
|
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
|
||||||
|
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||||
|
depends_on:
|
||||||
|
- antispam
|
||||||
|
|
||||||
|
antispam:
|
||||||
|
image: rspamd/rspamd:3.11
|
||||||
|
restart: unless-stopped
|
||||||
|
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
|
# Example host Apache vhost for the SelfPost panel (default scenario). Install
|
||||||
# scenario). Install on the host running Apache — NOT inside the SelfPost
|
# on the host running Apache — NOT inside the SelfPost container. Requires
|
||||||
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
|
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
|
||||||
# (`a2enmod ssl proxy proxy_http`).
|
|
||||||
#
|
#
|
||||||
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
# Certificates: obtain with the Apache certbot plugin against THIS vhost
|
||||||
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
|
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
|
||||||
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
|
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
|
||||||
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
|
# redirect vhost, and renews in the background via its own timer. The resulting
|
||||||
# The resulting PEM files land at
|
# PEM files land at
|
||||||
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
|
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
|
||||||
# — bind-mount that directory read-only into the SelfPost container (see
|
# bind-mount that directory read-only into the SelfPost container (see
|
||||||
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
|
||||||
# can present the very same certificate on 465/587. One certificate, two
|
# can present the very same certificate on 465/587. One certificate, two
|
||||||
# consumers (spec 10 p.4) — no separate cert just for the panel.
|
# consumers — no separate cert just for the panel.
|
||||||
|
|
||||||
<VirtualHost *:443>
|
<VirtualHost *:443>
|
||||||
ServerName mail.example.com
|
ServerName mail.example.com
|
||||||
@@ -26,9 +25,9 @@
|
|||||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||||
ProxyPassReverse "/" "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
|
# The panel is plain HTTP behind the proxy; it never terminates TLS itself.
|
||||||
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
|
# Cookies are still marked Secure by the panel because the browser only ever
|
||||||
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
|
# sees this HTTPS vhost.
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
|
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
|
||||||
# automatic ACME with no separate certbot container. Caddy writes certificates
|
# with no separate certbot container. Caddy writes certificates as plain PEM
|
||||||
# as plain PEM under its own data directory, which this fragment bind-mounts
|
# under its own data directory, which this fragment bind-mounts to the host so
|
||||||
# to the host so SelfPost can read the same files.
|
# SelfPost can read the same files.
|
||||||
#
|
#
|
||||||
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
|
# NOTE: Caddy's on-disk cert path includes
|
||||||
# the ACME CA's name as a path segment, e.g.
|
# the ACME CA's name as a path segment, e.g.
|
||||||
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
|
||||||
# This has been stable across recent Caddy releases but is an internal
|
# This has been stable across recent Caddy releases but is an internal
|
||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
environment:
|
environment:
|
||||||
# Caddy names certificate files after the hostname, not
|
# Caddy names certificate files after the hostname, not
|
||||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||||
@@ -31,6 +32,7 @@ services:
|
|||||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||||
|
|||||||
+48
-35
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
|
# SelfPost — default deployment, reverse-proxy = Apache.
|
||||||
#
|
#
|
||||||
# This file only runs SelfPost itself. Apache is assumed to already be
|
# This file only runs SelfPost itself. Apache is assumed to already be installed
|
||||||
# installed on the HOST (the target audience for this project typically runs
|
# on the HOST (the target audience for this project typically runs Apache there
|
||||||
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
|
# already) and reverse-proxies HTTPS for the panel; it is not containerised
|
||||||
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
|
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
|
||||||
# vhost fragment, and docs/guide.md "Reverse proxy" for the
|
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
|
||||||
# certbot steps that produce the PEM files this compose file mounts.
|
# this compose file mounts.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
# 1. Copy this file (and .env.example as .env) next to your own ./data and
|
||||||
@@ -15,32 +15,39 @@
|
|||||||
# TLS paths are fixed in this file to match that bind mount, not .env.
|
# TLS paths are fixed in this file to match that bind mount, not .env.
|
||||||
# 3. docker compose up -d
|
# 3. docker compose up -d
|
||||||
#
|
#
|
||||||
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
|
# The image tag below is FIXED on purpose: backup restore compares the manifest
|
||||||
# restore compares the manifest version against the running binary's version,
|
# version against the running binary's version, so ":latest" would make that
|
||||||
# so ":latest" would make that check meaningless. Bump the tag deliberately
|
# check meaningless. Bump the tag deliberately when you want to upgrade.
|
||||||
# when you want to upgrade.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.0.0
|
image: ghcr.io/mixeme/selfpost:1.9.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
# Path Postfix reads inside the container — matches the certs bind mount
|
# 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).
|
# below. Point these at your reverse-proxy's PEM output.
|
||||||
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
|
||||||
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
|
||||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
|
||||||
# alongside the primary 465/smtps listener (spec 5).
|
# the primary 465/smtps listener.
|
||||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||||
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
||||||
# limits (level 2) are configured later from the panel itself.
|
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
||||||
|
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||||
|
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
|
||||||
|
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||||
|
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
|
||||||
|
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
|
||||||
|
# configured later from the panel itself.
|
||||||
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
|
||||||
# How long the send log keeps rows before the background sweep deletes
|
# How long the send log keeps rows before the background sweep deletes
|
||||||
# them (spec 7.3, 9) — the main driver of /data growth over time.
|
# them — the main driver of /data growth over time.
|
||||||
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
|
||||||
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
|
# Sliding idle timeout for the panel login session, in days.
|
||||||
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
|
||||||
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
|
||||||
# report what the internet sees rather than what this host's own stub
|
# report what the internet sees rather than what this host's own stub
|
||||||
@@ -51,30 +58,36 @@ services:
|
|||||||
# client spoof its rate-limit key.
|
# client spoof its rate-limit key.
|
||||||
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
|
||||||
volumes:
|
volumes:
|
||||||
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
|
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
|
||||||
# sasldb2, sender map, setup token. Back this up (panel button or the
|
# sender map, setup token. Back this up (panel button or the
|
||||||
# selfpost-backup CLI) before you touch it directly.
|
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
|
||||||
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
|
# here too (./data/log, rotated daily, 14 files kept) so the delivery
|
||||||
# kept) so the delivery lines that resolve a queued send-log row survive
|
# lines that resolve a queued send-log row survive a container recreate;
|
||||||
# a container recreate; it is diagnostics and is left out of backups.
|
# it is diagnostics and is left out of backups.
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Read-only: SelfPost only ever reads certificates, never manages them
|
# Read-only project root for full backups: docker-compose.yml, .env, and
|
||||||
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||||
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
- .:/selfpost-deploy:ro
|
||||||
|
# Read-only: SelfPost only ever reads certificates, never manages them.
|
||||||
|
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
|
||||||
|
# /etc/letsencrypt on the host.
|
||||||
- ./certs:/etc/postfix/tls:ro
|
- ./certs:/etc/postfix/tls:ro
|
||||||
ports:
|
ports:
|
||||||
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
|
||||||
# published directly — mail traffic bypasses Apache entirely, it only
|
# published directly — mail traffic bypasses Apache entirely, it only ever
|
||||||
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
|
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
|
||||||
# (8080) is intentionally NOT published here: Apache reaches it over
|
# NOT published here: Apache reaches it over the host network at
|
||||||
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
|
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
|
||||||
# panel is never directly reachable from the internet without TLS.
|
# reachable from the internet without TLS.
|
||||||
- "465:465"
|
- "465:465"
|
||||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||||
# when the variable is true — see docs/guide.md "Published ports".
|
# when the variable is true — see docs/guide.md "Published ports".
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
|
||||||
|
# when the variable is true.
|
||||||
|
- "25:25"
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
# Hardening. SelfPost's entrypoint still needs to run as
|
||||||
# root very briefly to fix /data ownership and normalise permissions
|
# root very briefly to fix /data ownership and normalise permissions
|
||||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
||||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
# supervisord drops the panel to an unprivileged user — so this cannot be
|
||||||
@@ -82,7 +95,7 @@ services:
|
|||||||
# self-healing. What IS applied: no privilege escalation past what the
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
# image already grants, and every Linux capability dropped except the
|
# image already grants, and every Linux capability dropped except the
|
||||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
||||||
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
|
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
|
||||||
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||||
# dirs/files while still root (owner-check bypass);
|
# dirs/files while still root (owner-check bypass);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
|
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
|
||||||
# exactly like the Apache+certbot scenario — nginx and certbot are close
|
# Apache+certbot scenario — nginx and certbot are close cousins here, both
|
||||||
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
|
# writing/reading plain PEM under /etc/letsencrypt.
|
||||||
#
|
#
|
||||||
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
|
# 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
|
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
|
||||||
@@ -23,13 +23,15 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
# No host publish for 8080 here: nginx reaches it over the compose
|
# No host publish for 8080 here: nginx reaches it over the compose
|
||||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Same host directory certbot below writes into — plain bind mount,
|
- .:/selfpost-deploy:ro
|
||||||
# no named volume, so the PEM files are as directly inspectable as in
|
# Same host directory certbot below writes into — plain bind mount, no
|
||||||
# the Apache scenario (spec 10.3).
|
# named volume, so the PEM files are as directly inspectable as in the
|
||||||
|
# Apache scenario.
|
||||||
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
|
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
|
||||||
# over the compose network; mail (465/587) is published directly by the
|
# compose network; mail (465/587) is published directly by the selfpost
|
||||||
# selfpost container and never touches nginx.
|
# container and never touches nginx.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
|
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
|
||||||
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
|
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
|
||||||
# needs separate PEM files for Postfix, so an extraction step is required; see
|
# 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.
|
# 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
|
# Merge with the base file — run this from the deploy/ directory (Compose
|
||||||
@@ -15,8 +15,10 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
|
||||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
# Traefik keeps its certificates there, so a PEM extraction step is required.
|
||||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
# Run this on the host, after Traefik has issued or renewed the certificate, and
|
||||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
|
||||||
# is not itself watched by SelfPost/Postfix.
|
# by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+172
-37
@@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
|||||||
| Program | User | Priority | Role |
|
| Program | User | Priority | Role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
|
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
|
||||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
||||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||||
@@ -53,13 +53,45 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
|||||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||||
└─► outbound MX delivery (port 25 client)
|
└─► outbound MX delivery (port 25 client)
|
||||||
|
|
||||||
|
Internet ──► Postfix smtp inet :25 (when INBOUND_RELAY_ENABLE=true
|
||||||
|
│ and/or DMARC_REPORTS_ENABLE=true)
|
||||||
|
│
|
||||||
|
├─► optional antispam milter (inbound relay only)
|
||||||
|
├─► DMARC aggregate ingest (pipe → dmarc-ingest)
|
||||||
|
└─► smtp:[upstream]:port (inbound relay only;
|
||||||
|
transport_maps; no local delivery)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The smtp/inet listener is **absent** when both flags are off (`postconf -MX
|
||||||
|
smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||||
|
`smtp unix` client; it is not the same service.
|
||||||
|
|
||||||
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||||
|
|
||||||
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||||
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||||
`smtpd_tls_security_level=encrypt`.
|
`smtpd_tls_security_level=encrypt`.
|
||||||
|
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
|
||||||
|
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
|
||||||
|
`smtpd_milters` is always overridden on this service (to the antispam milter
|
||||||
|
or to nothing), because main.cf's submission chain would otherwise let a
|
||||||
|
signing outage defer relayed mail and would file inbound mail into the
|
||||||
|
outbound send log under the sender's domain.
|
||||||
|
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
|
||||||
|
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
|
||||||
|
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
|
||||||
|
`transport`, `relay_recipients`, `tls_policy`), written atomically by
|
||||||
|
[internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
|
||||||
|
an empty upstream host are omitted from the maps. Optional
|
||||||
|
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
|
||||||
|
DMARC-only port 25); default `milter_default_action` is fail-open
|
||||||
|
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
|
||||||
|
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
|
||||||
|
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
|
||||||
|
on, one listener serves both paths; per-IP rate uses
|
||||||
|
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
|
||||||
|
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
|
||||||
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||||
@@ -75,20 +107,34 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
|
|||||||
|
|
||||||
### Panel binary ([cmd/panel](../cmd/panel))
|
### Panel binary ([cmd/panel](../cmd/panel))
|
||||||
|
|
||||||
One process, three roles:
|
One process, four roles:
|
||||||
|
|
||||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||||
proxy only.
|
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
||||||
|
parameters (`queue_run_delay`, `minimal_backoff_time`,
|
||||||
|
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
|
||||||
|
`delay_warning_time`) and caches the snapshot on the handlers config. The
|
||||||
|
Mail queue card and a delivery's `deferred` / `bounced` history print those
|
||||||
|
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||||
|
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||||
|
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
|
||||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
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
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
count is the stored send-log rows plus the messages this process has admitted
|
ceilings apply to every client IP; an application ceiling **overrides** the
|
||||||
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
|
domain one for that login, higher or lower (guide § Rate limiting). Since
|
||||||
cannot each spend the same last slot; a reservation is released at
|
1.9.0 the client IP allow-list is a separate, restrictive control on the
|
||||||
end-of-message, on ABORT, or after a 10-minute TTL.
|
application (`applications.auth_ip_restrict`), checked here at `MAIL FROM`
|
||||||
|
and fail-open like the rest of this milter — not an authentication
|
||||||
|
boundary. 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
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||||
|
4. **rate-limit recalc** — every six hours (and on demand from the domain
|
||||||
|
page), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||||
|
|
||||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||||
|
|
||||||
@@ -152,31 +198,42 @@ state for an older message and the page reports it as such, not as a failure.
|
|||||||
## Panel HTTP surface
|
## Panel HTTP surface
|
||||||
|
|
||||||
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
|
||||||
unless noted. The table below is a summary — HTMX fragment endpoints
|
unless noted. Routes marked **global** return **404** for domain administrators
|
||||||
|
(`requireGlobal()` in
|
||||||
|
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
|
||||||
|
below is a summary — HTMX fragment endpoints
|
||||||
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
|
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
|
||||||
`/system-log/body`, …) and every POST variant live in `web.go`.
|
`/system-log/body`, …) and every POST variant live in `web.go`.
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/healthz` | Liveness (no auth) |
|
| `/healthz` | Liveness (no auth) |
|
||||||
|
| `/license` | Embedded `LICENSE` text (no auth) |
|
||||||
| `/setup/*` | One-time admin bootstrap |
|
| `/setup/*` | One-time admin bootstrap |
|
||||||
| `/login`, `/logout` | Session auth |
|
| `/login`, `/logout` | Session auth |
|
||||||
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
|
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
|
||||||
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
|
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
|
||||||
| `/domains/import` | Domain import (`POST`; form on the Backup page) |
|
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
|
||||||
| `/deliveries` | Send log with filters |
|
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
||||||
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines |
|
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
||||||
| `/mail-queue` | Postfix queue view |
|
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
|
||||||
| `/system-log` | `mail.log` tail |
|
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
|
||||||
| `/reload` | Reload OpenDKIM + Postfix maps |
|
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
||||||
| `/backup` | Full backup download (page also hosts the import form) |
|
| `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) |
|
||||||
| `/account` | Admin username/password |
|
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||||
|
| `/help` | In-panel operator help (any authenticated user) |
|
||||||
|
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||||
|
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
||||||
|
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
|
||||||
|
| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. |
|
||||||
|
|
||||||
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
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
|
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
|
`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
|
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||||
activity).
|
activity). The Mail queue retry-policy card is outside that fragment: it is the
|
||||||
|
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
|
||||||
|
not a live re-read.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
@@ -188,14 +245,17 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
|||||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||||
absolute cap (regular use keeps the session alive indefinitely).
|
absolute cap (regular use keeps the session alive indefinitely).
|
||||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||||
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
- **Password change** — all other sessions are deleted; the current session stays
|
- **Password change on `/settings`** — changing your own password deletes
|
||||||
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
every other session for that user; the current session stays active
|
||||||
[handlers_account.go](../internal/web/handlers_account.go)).
|
([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
|
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
|
||||||
|
A global administrator resetting another user's password on `/users` updates
|
||||||
|
the hash but does not delete that user's existing sessions.
|
||||||
|
|
||||||
Restoring an **older** backup also restores session rows: a session invalidated
|
Restoring an **older** backup also restores session rows: a session removed
|
||||||
after that backup was taken can become valid again if the browser still has the
|
after that backup was taken can become valid again if the browser still holds
|
||||||
cookie and idle timeout has not expired.
|
the cookie and the restored row's `expires_at` has not passed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,11 +278,21 @@ flowchart TB
|
|||||||
backupcli["selfpost-backup CLI"]
|
backupcli["selfpost-backup CLI"]
|
||||||
end
|
end
|
||||||
subgraph web ["internal/web — HTTP surface"]
|
subgraph web ["internal/web — HTTP surface"]
|
||||||
handlers["handlers_*.go, templates, session/security"]
|
webRoot["web.go — router, security"]
|
||||||
|
viewPkg["web/view — templates, static"]
|
||||||
|
authPkg["web/auth — session, login, setup"]
|
||||||
|
handlersPkg["web/handlers — authenticated pages"]
|
||||||
|
webRoot --> viewPkg
|
||||||
|
webRoot --> authPkg
|
||||||
|
webRoot --> handlersPkg
|
||||||
|
handlersPkg --> authPkg
|
||||||
|
handlersPkg --> viewPkg
|
||||||
end
|
end
|
||||||
subgraph services ["Services — multi-store operations + rollback"]
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
domainSvc["internal/domain"]
|
domainSvc["internal/domain"]
|
||||||
appSvc["internal/app"]
|
appSvc["internal/app"]
|
||||||
|
inboundSvc["internal/inbound"]
|
||||||
|
dmarcSvc["internal/dmarc"]
|
||||||
end
|
end
|
||||||
subgraph persistence ["Persistence"]
|
subgraph persistence ["Persistence"]
|
||||||
store["internal/store — SQLite, embedded migrations"]
|
store["internal/store — SQLite, embedded migrations"]
|
||||||
@@ -244,20 +314,27 @@ flowchart TB
|
|||||||
web --> store
|
web --> store
|
||||||
web --> domainSvc
|
web --> domainSvc
|
||||||
web --> appSvc
|
web --> appSvc
|
||||||
|
web --> inboundSvc
|
||||||
|
web --> dmarcSvc
|
||||||
web --> backupPkg
|
web --> backupPkg
|
||||||
web --> dnscheck
|
web --> dnscheck
|
||||||
web --> health
|
web --> health
|
||||||
web --> secretfile
|
web --> secretfile
|
||||||
domainSvc --> store
|
domainSvc --> store
|
||||||
appSvc --> store
|
appSvc --> store
|
||||||
|
inboundSvc --> store
|
||||||
|
dmarcSvc --> store
|
||||||
milterPkg --> store
|
milterPkg --> store
|
||||||
logtail --> store
|
logtail --> store
|
||||||
domainSvc --> postfix
|
domainSvc --> postfix
|
||||||
appSvc --> postfix
|
appSvc --> postfix
|
||||||
|
inboundSvc --> postfix
|
||||||
|
dmarcSvc --> postfix
|
||||||
```
|
```
|
||||||
|
|
||||||
The three roles inside the `panel` process (HTTP server, journal-milter,
|
The four roles inside the `panel` process (HTTP server, journal-milter,
|
||||||
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
|
log-tailer, rate-limit recalc) share one binary and one SQLite handle on
|
||||||
|
purpose — see
|
||||||
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
|
||||||
single-connection trade-off that follows from it.
|
single-connection trade-off that follows from it.
|
||||||
|
|
||||||
@@ -267,24 +344,42 @@ single-connection trade-off that follows from it.
|
|||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
| `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) |
|
||||||
| `setup-token` | First-run setup token file |
|
| `setup-token` | First-run setup token file |
|
||||||
| `opendkim/` | DKIM keys + tables |
|
| `opendkim/` | DKIM keys + tables |
|
||||||
| `sasl/sasldb2` | Application SASL credentials |
|
| `sasl/sasldb2` | Application SASL credentials |
|
||||||
| `postfix/sender_login_maps` | Login → From binding |
|
| `postfix/sender_login_maps` | Login → From binding |
|
||||||
|
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
|
||||||
|
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
|
||||||
|
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
|
||||||
|
| `postfix/tls_policy` | TLS policy for inbound next hops |
|
||||||
|
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
|
||||||
|
| `postfix/dmarc_transport` | Pipe transport for report ingest |
|
||||||
|
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
|
||||||
|
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
|
||||||
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||||
|
|
||||||
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||||
(transit mail not migrated by design).
|
full backups also archive the operator's `./certs` PEM files when present.
|
||||||
|
|
||||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||||
|
|
||||||
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
|
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
||||||
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
|
||||||
restore. Stopped-container `tar` of `./data` is safe (see guide).
|
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
||||||
|
version check on restore. Requires the project directory mounted read-only at
|
||||||
|
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||||
|
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
||||||
|
tables, Postfix's sender map, inbound relay maps (when
|
||||||
|
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
|
||||||
|
are re-derived from SQLite and both daemons are reloaded, so drift between the
|
||||||
|
extracted archive and the database is healed before mail flows. Manual
|
||||||
|
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and
|
||||||
|
inbound maps only — not DMARC maps. Stopped-container
|
||||||
|
`tar` of `./data` alone remains possible for state-only copies (see guide).
|
||||||
|
|
||||||
**Optional encryption** of the two secret-bearing downloads
|
**Optional encryption** of the two secret-bearing downloads
|
||||||
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||||
@@ -306,5 +401,45 @@ origin check, no CSRF tokens) are documented there separately.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Public and internal env vars: [guide § Environment variables](guide.md#environment-variables).
|
Public env vars: [guide § Environment variables](guide.md#environment-variables).
|
||||||
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
|
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
|
||||||
|
|
||||||
|
**Internal env vars.** The following are read by the panel or startup scripts
|
||||||
|
but are not part of the operator interface — not meant to be changed in a
|
||||||
|
normal deployment; documented here so an accidental override reads as
|
||||||
|
unsupported rather than as a missing doc:
|
||||||
|
|
||||||
|
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
||||||
|
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
||||||
|
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
||||||
|
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
||||||
|
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
||||||
|
here has to be matched in `build/postfix-config.sh`),
|
||||||
|
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
||||||
|
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
|
||||||
|
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
||||||
|
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
|
||||||
|
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||||
|
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||||
|
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
||||||
|
desyncs the map Postfix reads from the file the panel maintains),
|
||||||
|
`POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`),
|
||||||
|
`POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`),
|
||||||
|
`POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`),
|
||||||
|
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
|
||||||
|
overridden without matching the panel writer in
|
||||||
|
[internal/postfix/inbound.go](../internal/postfix/inbound.go),
|
||||||
|
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
|
||||||
|
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
|
||||||
|
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
|
||||||
|
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
|
||||||
|
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
|
||||||
|
full backups; mount `.:/selfpost-deploy:ro` in compose).
|
||||||
|
- **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`).
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 854 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — panel mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var map = {
|
||||||
|
login: "login.html",
|
||||||
|
setup: "setup.html",
|
||||||
|
status: "status.html",
|
||||||
|
domains: "domains.html",
|
||||||
|
domain: "domain.html",
|
||||||
|
"domain-delete": "domain-delete.html",
|
||||||
|
deliveries: "deliveries.html",
|
||||||
|
delivery: "delivery.html",
|
||||||
|
"mail-queue": "mail-queue.html",
|
||||||
|
"system-log": "system-log.html",
|
||||||
|
backup: "backup.html",
|
||||||
|
users: "users.html",
|
||||||
|
"user-form": "user-form.html",
|
||||||
|
"user-delete": "user-delete.html",
|
||||||
|
settings: "settings.html",
|
||||||
|
inbound: "inbound.html",
|
||||||
|
"inbound-domain": "inbound-domain.html",
|
||||||
|
"inbound-backup": "inbound-backup.html",
|
||||||
|
"inbound-delete": "inbound-delete.html",
|
||||||
|
dmarc: "dmarc.html",
|
||||||
|
"dmarc-domain": "dmarc-domain.html",
|
||||||
|
"dmarc-report": "dmarc-report.html",
|
||||||
|
"dmarc-report-fail": "dmarc-report-fail.html",
|
||||||
|
help: "help.html"
|
||||||
|
};
|
||||||
|
var h = location.hash.replace(/^#/, "");
|
||||||
|
location.replace((map[h] || "status.html") + location.search);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Backup & migration — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Backup & migration</h1>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
|
||||||
|
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password" placeholder="at least 8 characters">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
<p class="muted">Keep this password: without it the file cannot be opened.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.</p>
|
||||||
|
<label>Domain export file</label>
|
||||||
|
<input type="file">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="off">
|
||||||
|
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Deliveries — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Deliveries</h1>
|
||||||
|
<form class="toolbar fill" action="#" onsubmit="return false">
|
||||||
|
<div class="field"><label>Domain</label>
|
||||||
|
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Application</label>
|
||||||
|
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
|
||||||
|
</div>
|
||||||
|
<button type="button">Filter</button>
|
||||||
|
</form>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:14:02</td>
|
||||||
|
<td>billing@example.com</td>
|
||||||
|
<td>ada@example.net</td>
|
||||||
|
<td class="subject"><span>Invoice #4412</span></td>
|
||||||
|
<td class="status"><span class="st st-warn">deferred</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:11:40</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>list-bounces@example.net</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 19:02:11</td>
|
||||||
|
<td>alerts@alerts.example.com</td>
|
||||||
|
<td>noreply@blocked.example</td>
|
||||||
|
<td class="subject"><span>Disk 92% on web-3</span></td>
|
||||||
|
<td class="status"><span class="st st-error">bounced</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 18:44:09</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>sam@example.org</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-warn">deferred</span><span class="when">20:14</span>
|
||||||
|
<span class="meta">billing@example.com → ada@example.net</span>
|
||||||
|
<span class="subj">Invoice #4412</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-ok">delivered</span><span class="when">20:11</span>
|
||||||
|
<span class="meta">news@example.com → list-bounces@example.net</span>
|
||||||
|
<span class="subj">August digest</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-error">bounced</span><span class="when">19:02</span>
|
||||||
|
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
|
||||||
|
<span class="subj">Disk 92% on web-3</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Invoice #4412 — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">Invoice #4412</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">billing@example.com</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="addr">ada@example.net</span>
|
||||||
|
<span class="st st-warn">deferred</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="deliveries.html">← Back to deliveries</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Message</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>History</h2>
|
||||||
|
<ol class="timeline">
|
||||||
|
<li class="event lvl-ok">
|
||||||
|
<p class="event-time">2026-08-15 20:14:02 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
|
||||||
|
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event lvl-warn">
|
||||||
|
<p class="event-time">2026-08-15 20:14:08 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
|
||||||
|
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event pending">
|
||||||
|
<p class="event-time">not yet</p>
|
||||||
|
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Delivery log</h2>
|
||||||
|
<table class="log desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com></td></tr>
|
||||||
|
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="phone-only">
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
|
||||||
|
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
|
||||||
|
<span class="code">client=203.0.113.40, sasl_username=billing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — DMARC — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com <span class="tag future">candidate</span></h1>
|
||||||
|
<a class="back g-only" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
|
||||||
|
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
|
||||||
|
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Third-party senders</h2>
|
||||||
|
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">google.com · 6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
|
||||||
|
<span class="meta">google.com · 2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sources · last 7 days</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
|
||||||
|
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
|
||||||
|
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — alerts.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">alerts.example.com</span>
|
||||||
|
<span class="st st-ok">88 pass</span>
|
||||||
|
<span class="st st-error">19 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card attn">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">198.51.100.80 (unknown)</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td><span class="st st-error">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 88</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>unknown · 19</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">example.com</span>
|
||||||
|
<span class="st st-ok">412 pass</span>
|
||||||
|
<span class="st st-warn">6 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc-domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">66.102.1.44 (google.com)</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td><span class="st st-warn">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 412</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>google.com · 6</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DMARC reports — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>DMARC reports <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ingest <span class="st st-ok">ok</span></h2>
|
||||||
|
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>This week</h2>
|
||||||
|
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 04:40</td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
|
||||||
|
<span class="meta">example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report-fail.html">
|
||||||
|
<span class="st st-error">19 fail</span><span class="when">04:40</span>
|
||||||
|
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">88 pass</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">example.com · google.com · 13 Aug</span>
|
||||||
|
<span class="subj">2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete example.com</h1>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete <strong>example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>permanently delete its DKIM signing key;</li>
|
||||||
|
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
|
||||||
|
<li>reload OpenDKIM so the domain is no longer signed.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">This cannot be undone.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com</h1>
|
||||||
|
<a class="back" href="domains.html">← All domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card credential" id="new-credential">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
|
||||||
|
<label>Login</label>
|
||||||
|
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Password</label>
|
||||||
|
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="dns-status">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DKIM <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>SPF <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DMARC <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">_dmarc.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">p=none; rua points at SelfPost ingest.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Report authorization</label>
|
||||||
|
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="dkim-spf">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DKIM and SPF records</h2>
|
||||||
|
<label class="help-link" for="help-records" title="What to publish">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="check-col-title">DKIM</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="check-col-title">SPF</p>
|
||||||
|
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="dmarc-record">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value <span class="muted">(from Settings)</span></label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="connection">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
<label class="help-link" for="help-connection" title="How to connect">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Server</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Port and encryption</label>
|
||||||
|
<span class="code">465 — SSL/TLS (implicit)
|
||||||
|
587 — STARTTLS (submission)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="add-application">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Add an application</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="What an application is">?</label>
|
||||||
|
</div>
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Login</label>
|
||||||
|
<input placeholder="prod-server" autocomplete="off">
|
||||||
|
<label>Address mode</label>
|
||||||
|
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Create application</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="applications">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Applications</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<ul class="apps">
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">newsletter</p>
|
||||||
|
<p class="app-addr muted">Any address of the domain — *@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
|
||||||
|
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
<div class="panel panel-edit">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Address mode</p>
|
||||||
|
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
|
||||||
|
<textarea rows="1">203.0.113.40</textarea>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="80">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">billing</p>
|
||||||
|
<p class="app-addr muted">Fixed list — invoices@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button">Edit</button>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="domain-settings-dmarc">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC reports</h2>
|
||||||
|
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Aggregate reports (rua=)</label>
|
||||||
|
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="domain-settings-limit">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Save limit</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="export-domain">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Export domain</h2>
|
||||||
|
<label class="help-link" for="help-export" title="What the file contains">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Export domain</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Domains — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domains" data-page="domains" data-nav="domains" data-title="Domains">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head"><h1>Domains</h1></div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Domains</h2>
|
||||||
|
<form class="measure g-only" action="#" onsubmit="return false;">
|
||||||
|
<label for="dname">Add a sending domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="dname" placeholder="example.com" autocomplete="off">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="domain.html">ok</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">alerts.example.com</a></td>
|
||||||
|
<td><a class="st st-warn" href="domain.html">warn</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="domain.html"><span>example.com</span><span class="st st-ok">ok</span><span class="meta">2 apps · selector mail</span></a></li>
|
||||||
|
<li><a href="domain.html"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="meta">1 app · selector mail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Help — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-help" data-page="help" data-nav="help" data-title="Help">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Help <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.</p>
|
||||||
|
<div class="measure stack">
|
||||||
|
<div class="card">
|
||||||
|
<h2>On this panel</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="help.html">Machine</a> — kernel counters and the rate window</li>
|
||||||
|
<li><a href="help.html">TLS certificate</a> — port 465, reverse-proxy mount</li>
|
||||||
|
<li><a href="help.html">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
|
||||||
|
<li><a href="help.html">Mail queue retries</a> — time-based, no attempt budget</li>
|
||||||
|
<li><a href="help.html">Inbound</a> — not mailboxes; listed recipients or any address at the domain</li>
|
||||||
|
<li>Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Machine</h2>
|
||||||
|
<p>CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>TLS certificate</h2>
|
||||||
|
<p>Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hostname / reverse DNS</h2>
|
||||||
|
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>backup.example.net — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-backup" data-page="inbound-backup" data-nav="inbound" data-title="backup.example.net" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>backup.example.net</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-error">error</span></h2>
|
||||||
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||||
|
<label>MX <span class="st st-error">error</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">backup.example.net</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.primary.example.net.</span>
|
||||||
|
<p class="error">No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="192.0.2.20">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option>Required</option><option selected>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>MX record to publish</h2>
|
||||||
|
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">backup.example.net</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">20 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<h2>Valid recipients</h2>
|
||||||
|
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list">Listed addresses only</option>
|
||||||
|
<option value="any" selected>Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">postmaster@backup.example.net
|
||||||
|
abuse@backup.example.net</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-delete" data-page="inbound-delete" data-nav="inbound" data-title="Delete inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound-domain.html">← Back to lists.example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to stop accepting inbound mail for <strong>lists.example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
|
||||||
|
<li>stop forwarding to <strong>10.0.0.8:25</strong>;</li>
|
||||||
|
<li>leave outbound sending domains untouched.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete lists.example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-domain" data-page="inbound-domain" data-nav="inbound" data-title="lists.example.com" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||||
|
<label>MX <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.example.org.
|
||||||
|
20 mail.primary.example.net.</span>
|
||||||
|
<p class="muted">An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="10.0.0.8">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>MX record to publish</h2>
|
||||||
|
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<h2>Valid recipients</h2>
|
||||||
|
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list" selected>Listed addresses only</option>
|
||||||
|
<option value="any">Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">staff@lists.example.com
|
||||||
|
postmaster@lists.example.com
|
||||||
|
abuse@lists.example.com</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Inbound — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound" data-page="inbound" data-nav="inbound" data-title="Inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Inbound <span class="tag">1.x</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.</p>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Forwarding</h2>
|
||||||
|
<form class="measure" action="#" onsubmit="return false">
|
||||||
|
<label for="iname">Add inbound domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="iname" placeholder="lists.example.com">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">lists.example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="inbound-domain.html">ok</a></td>
|
||||||
|
<td class="muted">10.0.0.8:25</td>
|
||||||
|
<td>12 listed</td>
|
||||||
|
<td><span class="st st-ok">required</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-backup.html">backup.example.net</a></td>
|
||||||
|
<td><a class="st st-error" href="inbound-backup.html">error</a></td>
|
||||||
|
<td class="muted">192.0.2.20:25</td>
|
||||||
|
<td>any</td>
|
||||||
|
<td><span class="st st-unknown">off</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="inbound-domain.html"><span>lists.example.com</span><span class="st st-ok">ok</span><span class="meta">12 listed · 10.0.0.8:25</span></a></li>
|
||||||
|
<li><a href="inbound-backup.html"><span>backup.example.net</span><span class="st st-error">error</span><span class="meta">any recipient · 192.0.2.20:25</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a <em>Re-check</em> button.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!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="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">макеты · не панель</p>
|
||||||
|
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
|
||||||
|
<p>Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="status.html">Открыть Status</a>
|
||||||
|
<a class="cta quiet" href="system.html">Система интерфейса</a>
|
||||||
|
<a class="cta quiet" href="status.html?view=phone">Аварийный телефон</a>
|
||||||
|
<a class="cta quiet" href="status.html?theme=dark">Status, тёмная тема</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Система, не рескин</h2>
|
||||||
|
<p>Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. <a href="system.html">Грамматика</a> задаёт регионы (<code>measure</code> / <code>pair</code> / <code>fill</code>), карточку со слотом справки, <code>field-row</code> (Host и Type одной высоты) и <code>actions-row</code>. Макеты собраны только из этого: один экран — один HTML-файл.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Ширина окна</h2>
|
||||||
|
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.</p>
|
||||||
|
<h3>Гибрид</h3>
|
||||||
|
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — <code>fill</code>. Одиночные формы (подтверждения, форма пользователя) остаются <strong>42rem и выровнены влево</strong>. Login и setup — по-прежнему узкий центрированный блок.</p>
|
||||||
|
<div class="compare">
|
||||||
|
<div>
|
||||||
|
<div class="row">сейчас, окно ~1600px</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:42%">64rem</i>
|
||||||
|
<i class="empty">пусто</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, ops-страница</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="fill">контент до 90rem</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, форма</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:28%">42rem</i>
|
||||||
|
<i class="empty">поле не растягивает инпуты</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Информационная архитектура</h2>
|
||||||
|
<div class="ia">
|
||||||
|
<div>
|
||||||
|
<h3>Global administrator</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Status</li>
|
||||||
|
<li>Domains → domain → delete</li>
|
||||||
|
<li>Deliveries → message</li>
|
||||||
|
<li>Mail queue</li>
|
||||||
|
<li>System log</li>
|
||||||
|
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
|
||||||
|
<li>DMARC reports <span class="tag future">candidate</span></li>
|
||||||
|
<li>Backup, Users, Settings</li>
|
||||||
|
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status и страницы домена</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Domain-admin</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Domains (только назначенные) → domain → DMARC reports этого домена</li>
|
||||||
|
<li>Deliveries → message, включая пояснение retry</li>
|
||||||
|
<li>Settings — только учётные данные панели</li>
|
||||||
|
<li>Help drawer</li>
|
||||||
|
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Знак и новые иконки</h2>
|
||||||
|
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
|
||||||
|
<div class="mark-row">
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
|
||||||
|
<figcaption>эталон · login / setup</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
|
||||||
|
<figcaption>колонка навбара</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
|
||||||
|
<figcaption>телефон · шапка drawer</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<h3>Иконки новых пунктов</h3>
|
||||||
|
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
|
||||||
|
<div class="icons-row">
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>
|
||||||
|
<span>Inbound</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
|
||||||
|
<span>DMARC</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
|
||||||
|
<span>Help</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Экраны</h2>
|
||||||
|
<h3>Система</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="system.html">Грамматика и живые образцы</a>
|
||||||
|
</div>
|
||||||
|
<h3>Вход</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="login.html">Sign in</a>
|
||||||
|
<a href="setup.html">Create administrator</a>
|
||||||
|
</div>
|
||||||
|
<h3>Текущая панель</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html">Status</a>
|
||||||
|
<a href="domains.html">Domains</a>
|
||||||
|
<a href="domain.html">Domain detail</a>
|
||||||
|
<a href="domain-delete.html">Delete domain</a>
|
||||||
|
<a href="deliveries.html">Deliveries</a>
|
||||||
|
<a href="delivery.html">Delivery (deferred + retries)</a>
|
||||||
|
<a href="mail-queue.html">Mail queue + retry policy</a>
|
||||||
|
<a href="system-log.html">System log</a>
|
||||||
|
<a href="backup.html">Backup</a>
|
||||||
|
<a href="users.html">Users</a>
|
||||||
|
<a href="user-form.html">Edit user</a>
|
||||||
|
<a href="user-delete.html">Delete user</a>
|
||||||
|
<a href="settings.html">Settings</a>
|
||||||
|
</div>
|
||||||
|
<h3>Roadmap</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="mail-queue.html">queue-retries — карточка на Mail queue</a>
|
||||||
|
<a href="delivery.html">queue-retries — history deferred</a>
|
||||||
|
<a href="inbound.html">Inbound list (колонка DNS = MX)</a>
|
||||||
|
<a href="inbound-domain.html">Inbound domain — DNS ok</a>
|
||||||
|
<a href="inbound-backup.html">Inbound domain — DNS error</a>
|
||||||
|
<a href="inbound-delete.html">Delete inbound domain</a>
|
||||||
|
<a href="status.html?inbound=0">Inbound выключен (пункта нет)</a>
|
||||||
|
<a href="dmarc.html">DMARC reports hub <span class="tag future">candidate</span></a>
|
||||||
|
<a href="dmarc-domain.html">DMARC — domain roll-up</a>
|
||||||
|
<a href="dmarc-report.html">DMARC report — aligned</a>
|
||||||
|
<a href="dmarc-report-fail.html">DMARC report — third-party fail</a>
|
||||||
|
<a href="help.html">Help <span class="tag future">candidate</span></a>
|
||||||
|
</div>
|
||||||
|
<h3>Аварийный телефон</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html?view=phone">Status 390px — Overall, then queue/cert, then the rest</a>
|
||||||
|
<a href="mail-queue.html?view=phone">Mail queue 390px</a>
|
||||||
|
<a href="deliveries.html?view=phone">Deliveries 390px — список, не таблица</a>
|
||||||
|
<a href="delivery.html?view=phone">Delivery 390px</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="border-bottom:none">
|
||||||
|
<h2>Как смотреть</h2>
|
||||||
|
<p>Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.</p>
|
||||||
|
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-login" data-page="login" data-nav="login" data-title="Sign in" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<div class="card">
|
||||||
|
<form action="status.html">
|
||||||
|
<label for="u">Username</label>
|
||||||
|
<input id="u" name="username" autocomplete="username">
|
||||||
|
<label for="p">Password</label>
|
||||||
|
<input id="p" name="password" type="password" autocomplete="current-password">
|
||||||
|
<div class="actions-row"><button type="submit">Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mail queue — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-mail-queue" data-page="mail-queue" data-nav="queue" data-title="Mail queue" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Mail queue</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>How delivery retries work</h2>
|
||||||
|
<p class="muted">This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.</p>
|
||||||
|
<div class="facts retry-facts">
|
||||||
|
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pending messages</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3010</span><span class="when">11 min</span><span class="meta">news@example.com → pat@slow.example</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3102</span><span class="when">4 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<label>postqueue -p</label>
|
||||||
|
<span class="code">-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||||
|
4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com
|
||||||
|
ada@example.net
|
||||||
|
4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com
|
||||||
|
pat@slow.example
|
||||||
|
-- 3 Kbytes in 3 Requests.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css.
|
||||||
|
This file is a design artifact, not the panel stylesheet. */
|
||||||
|
|
||||||
|
@import url("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");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--bg: #f4f2ed; --fg: #12161c;
|
||||||
|
--muted: #6b7684;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--border: #dedcd7;
|
||||||
|
--control-border: #cbc8c1;
|
||||||
|
--input-bg: #fff;
|
||||||
|
--code-bg: #efede9;
|
||||||
|
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||||
|
--nav-active-bg: #ede4de;
|
||||||
|
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||||
|
--on-accent: #fff;
|
||||||
|
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
|
||||||
|
--credential-bg: #fcf6e4; --credential-border: #e0c874;
|
||||||
|
--danger-fill: #b42318; --danger-fill-hover: #912018;
|
||||||
|
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
|
||||||
|
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
|
||||||
|
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
|
||||||
|
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
|
||||||
|
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
|
||||||
|
--nav-w: 14rem;
|
||||||
|
--ops-max: 90rem;
|
||||||
|
--form-max: 42rem;
|
||||||
|
--auth-max: 24rem;
|
||||||
|
--gallery-h: 3.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-tokens {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html:not(.force-light) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scrollbar-gutter: stable; }
|
||||||
|
body {
|
||||||
|
font: 400 15px/1.5 var(--font-sans);
|
||||||
|
margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
/* —— Gallery chrome (index + prototype toolbar) —— */
|
||||||
|
.gallery {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem;
|
||||||
|
min-height: var(--gallery-h);
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.gallery a { color: var(--accent-text); }
|
||||||
|
.gallery .brand-mini {
|
||||||
|
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.gallery .seg {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; }
|
||||||
|
.gallery .seg label {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-global:checked ~ .gallery label[for="role-global"],
|
||||||
|
#role-domain:checked ~ .gallery label[for="role-domain"],
|
||||||
|
#vp-desktop:checked ~ .gallery label[for="vp-desktop"],
|
||||||
|
#vp-phone:checked ~ .gallery label[for="vp-phone"],
|
||||||
|
#theme-light:checked ~ .gallery label[for="theme-light"],
|
||||||
|
#theme-dark:checked ~ .gallery label[for="theme-dark"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill); font-weight: 600;
|
||||||
|
}
|
||||||
|
.gallery .check-lab {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.gallery .check-lab input { width: auto; margin: 0; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
|
||||||
|
font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.4rem; border-radius: 4px;
|
||||||
|
background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); }
|
||||||
|
|
||||||
|
body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; }
|
||||||
|
|
||||||
|
#theme-dark:checked ~ .gallery,
|
||||||
|
#theme-dark:checked ~ .app,
|
||||||
|
#theme-dark:checked ~ .help-drawer {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— Prototype shell: nav docked left, content uses remaining width —— */
|
||||||
|
.app {
|
||||||
|
display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h));
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.stage { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.nav {
|
||||||
|
position: sticky; top: var(--gallery-h); align-self: stretch;
|
||||||
|
flex: none; width: var(--nav-w);
|
||||||
|
display: flex; flex-direction: column; gap: 0.75rem;
|
||||||
|
padding: 1.1rem 0.85rem 1.4rem;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
max-height: calc(100vh - var(--gallery-h)); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||||
|
.nav .session-user { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.nav .brand { padding: 0; display: block; }
|
||||||
|
.nav .brand img { display: block; width: 100%; height: auto; }
|
||||||
|
.nav a, .nav [aria-current], .nav .current {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.nav a:hover { background: var(--surface-bg); }
|
||||||
|
.nav [aria-current], .nav .current {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
body.page-status .n-status,
|
||||||
|
body.page-domains .n-domains,
|
||||||
|
body.page-domain .n-domains,
|
||||||
|
body.page-domain-delete .n-domains,
|
||||||
|
body.page-deliveries .n-deliveries,
|
||||||
|
body.page-delivery .n-deliveries,
|
||||||
|
body.page-mail-queue .n-queue,
|
||||||
|
body.page-system-log .n-log,
|
||||||
|
body.page-inbound .n-inbound,
|
||||||
|
body.page-inbound-domain .n-inbound,
|
||||||
|
body.page-inbound-backup .n-inbound,
|
||||||
|
body.page-inbound-delete .n-inbound,
|
||||||
|
body.page-dmarc .n-dmarc,
|
||||||
|
body.page-dmarc-domain .n-dmarc,
|
||||||
|
body.page-dmarc-report .n-dmarc,
|
||||||
|
body.page-dmarc-report-fail .n-dmarc,
|
||||||
|
body.page-backup .n-backup,
|
||||||
|
body.page-users .n-users,
|
||||||
|
body.page-user-form .n-users,
|
||||||
|
body.page-user-delete .n-users,
|
||||||
|
body.page-help .n-help,
|
||||||
|
body.page-settings .n-settings {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||||
|
.nav button, .nav .btn-ghost {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400;
|
||||||
|
color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border);
|
||||||
|
border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%;
|
||||||
|
}
|
||||||
|
.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); }
|
||||||
|
|
||||||
|
.phone-bar { display: none; }
|
||||||
|
.phone-bar .icon { width: 1.1rem; height: 1.1rem; }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
#feat-inbound:checked ~ .gallery label[for="feat-inbound"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill);
|
||||||
|
}
|
||||||
|
.gallery label[for="feat-inbound"] {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-domain:checked ~ .gallery .g-only { display: none; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1 1 auto; min-width: 0; width: 100%;
|
||||||
|
padding: 1.5rem 1.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
main.auth {
|
||||||
|
max-width: none; display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; }
|
||||||
|
main.stack > .page-head,
|
||||||
|
main.stack > h1 { margin-bottom: 0; }
|
||||||
|
main.stack > .back { margin: 0; }
|
||||||
|
main.stack > .route { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > p.muted { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); }
|
||||||
|
|
||||||
|
body.page-login .nav,
|
||||||
|
body.page-setup .nav,
|
||||||
|
body.page-login .phone-bar,
|
||||||
|
body.page-setup .phone-bar { display: none !important; }
|
||||||
|
body.page-login .app,
|
||||||
|
body.page-setup .app { display: block; }
|
||||||
|
|
||||||
|
#role-domain:checked ~ .app .g-only { display: none !important; }
|
||||||
|
#role-domain:checked ~ .app .pair:has(> .g-only) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; }
|
||||||
|
#feat-inbound:checked ~ .app .in-off { display: none !important; }
|
||||||
|
|
||||||
|
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; }
|
||||||
|
h1.subject { overflow-wrap: anywhere; }
|
||||||
|
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||||
|
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1.25rem 1.4rem; margin: 0;
|
||||||
|
}
|
||||||
|
.card.narrow { max-width: var(--auth-max); }
|
||||||
|
.card + .card { margin-top: 1rem; }
|
||||||
|
.card.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||||
|
a { color: var(--accent-text); }
|
||||||
|
.back { display: block; margin: -0.4rem 0 1rem; }
|
||||||
|
.flash {
|
||||||
|
background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg);
|
||||||
|
padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
|
||||||
|
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.version a { color: inherit; }
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||||
|
label.check {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
fieldset {
|
||||||
|
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
}
|
||||||
|
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
||||||
|
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
|
||||||
|
|
||||||
|
button, a.btn, a.danger {
|
||||||
|
display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem;
|
||||||
|
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||||
|
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||||
|
button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); }
|
||||||
|
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
.form-actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.form-actions > button, .form-actions > a.btn, .form-actions > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
.st {
|
||||||
|
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||||
|
letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||||
|
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||||
|
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||||
|
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||||
|
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||||
|
}
|
||||||
|
td.actions { text-align: right; }
|
||||||
|
.metric { white-space: nowrap; }
|
||||||
|
.card:has(table) { overflow-x: auto; }
|
||||||
|
td.subject span {
|
||||||
|
display: block; max-width: 22rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: block; white-space: pre; overflow-x: auto; overflow-wrap: normal;
|
||||||
|
word-break: normal; font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
||||||
|
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.input-row input { flex: 1; min-width: 0; }
|
||||||
|
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: 1rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
h1 + .split, h1 + form > .split { margin-top: 0; }
|
||||||
|
.split > .card { min-width: 0; }
|
||||||
|
.split > .card + .card { margin-top: 0; }
|
||||||
|
.split + .card, .card + .split, form:has(.split) + .card { margin-top: 1rem; }
|
||||||
|
#settings form { width: 100%; max-width: none; }
|
||||||
|
#settings .split,
|
||||||
|
#backup > .split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-cols {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.check-col { min-width: 0; }
|
||||||
|
.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; }
|
||||||
|
.field-pair {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||||
|
gap: 0 1rem; margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-pair > div { min-width: 0; }
|
||||||
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
|
/* Host / name beside Type — size the type column to that token rather than
|
||||||
|
giving it half the row. Vertical padding matches .code-row .code so Type is
|
||||||
|
the same height as Host when Copy sits beside it. */
|
||||||
|
.field-pair.host-type {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.field-pair.host-type .code {
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-type { width: max-content; }
|
||||||
|
.field-type .code {
|
||||||
|
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||||
|
.fact-label {
|
||||||
|
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.fact-value { display: block; margin-top: 0.1rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.event { position: relative; min-width: 0; }
|
||||||
|
.event::before {
|
||||||
|
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
|
||||||
|
width: 0.65rem; height: 0.65rem; border-radius: 50%;
|
||||||
|
background: var(--card-bg); border: 2px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
|
||||||
|
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
|
||||||
|
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
|
||||||
|
.event.pending { opacity: 0.7; }
|
||||||
|
.event.pending::before { border-style: dashed; }
|
||||||
|
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||||
|
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; }
|
||||||
|
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; white-space: nowrap; overflow-x: auto; max-width: 100%; }
|
||||||
|
.route .arrow { color: var(--muted); }
|
||||||
|
|
||||||
|
table.log { margin-top: 1rem; }
|
||||||
|
table.log th:first-child, table.log td.time { width: 1%; }
|
||||||
|
table.log td.log-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem; white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.copy, .actions button, .actions > label.toggle, .actions a.danger {
|
||||||
|
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||||
|
border-radius: 5px; white-space: nowrap;
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); }
|
||||||
|
.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
||||||
|
|
||||||
|
.apps { list-style: none; margin: 1rem 0 0; padding: 0; }
|
||||||
|
.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||||
|
.app-item:last-child { padding-bottom: 0; }
|
||||||
|
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
.app-addr { margin: 0.15rem 0 0; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.app-item .actions { margin-top: 0.7rem; }
|
||||||
|
.app-item .actions > .panel-toggle {
|
||||||
|
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-item .actions > .panel { display: none; flex: 1 0 100%; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||||
|
.app-item .actions > .panel button { margin-top: 0.9rem; }
|
||||||
|
|
||||||
|
.encrypt { margin-top: 1.2rem; }
|
||||||
|
.encrypt-fields {
|
||||||
|
display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; }
|
||||||
|
.encrypt-fields label { margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.rcpt-list, .rcpt-any { display: none; }
|
||||||
|
.rcpt-mode:has(option[value="list"]:checked) .rcpt-list { display: block; }
|
||||||
|
.rcpt-mode:has(option[value="any"]:checked) .rcpt-any { display: block; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end;
|
||||||
|
padding: 0.85rem 1rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.toolbar .field { flex: 1 1 10rem; min-width: 8rem; }
|
||||||
|
.toolbar label { margin-top: 0; font-size: 0.8rem; }
|
||||||
|
.toolbar button { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Status: hero + dense grid */
|
||||||
|
.status-hero {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem;
|
||||||
|
padding: 1rem 1.25rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.status-hero.attn,
|
||||||
|
.card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.status-hero h1 { margin: 0; }
|
||||||
|
.status-hero .lead { margin: 0; color: var(--fg); }
|
||||||
|
.status-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.status-grid .card { margin: 0; }
|
||||||
|
.status-grid .card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.metric-row {
|
||||||
|
display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline;
|
||||||
|
padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.metric-row:last-child { border-bottom: 0; }
|
||||||
|
.metric-row .k { font-weight: 600; }
|
||||||
|
.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.help-link {
|
||||||
|
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted);
|
||||||
|
text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600;
|
||||||
|
background: var(--surface-bg); cursor: pointer; margin: 0;
|
||||||
|
}
|
||||||
|
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-head h1 { margin: 0; }
|
||||||
|
|
||||||
|
/* Compact retry policy */
|
||||||
|
.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.phone-list { display: none; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.phone-list li { border-bottom: 1px solid var(--border); }
|
||||||
|
.phone-list a,
|
||||||
|
.phone-list .item {
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem;
|
||||||
|
padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg);
|
||||||
|
}
|
||||||
|
.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.phone-list .subj { grid-column: 1 / -1; margin: 0; }
|
||||||
|
|
||||||
|
/* Help drawer — CSS checkbox, no script required */
|
||||||
|
.help-drawer {
|
||||||
|
display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0;
|
||||||
|
width: min(26rem, 100vw); z-index: 30;
|
||||||
|
background: var(--card-bg); border-left: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
|
||||||
|
.help-pane { display: none; }
|
||||||
|
html:has(#help-index:checked) .help-pane-index,
|
||||||
|
html:has(#help-status:checked) .help-pane-status,
|
||||||
|
html:has(#help-password:checked) .help-pane-password,
|
||||||
|
html:has(#help-dns:checked) .help-pane-dns,
|
||||||
|
html:has(#help-records:checked) .help-pane-records,
|
||||||
|
html:has(#help-dmarc:checked) .help-pane-dmarc,
|
||||||
|
html:has(#help-connection:checked) .help-pane-connection,
|
||||||
|
html:has(#help-apps:checked) .help-pane-apps,
|
||||||
|
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
|
||||||
|
html:has(#help-export:checked) .help-pane-export { display: block; }
|
||||||
|
.help-scrim {
|
||||||
|
display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25;
|
||||||
|
background: rgba(18, 22, 28, 0.28);
|
||||||
|
}
|
||||||
|
.help-drawer h2 { margin-top: 1.2rem; }
|
||||||
|
.help-drawer h2:first-of-type,
|
||||||
|
.help-pane h2 { margin-top: 0; }
|
||||||
|
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
|
||||||
|
.help-drawer .toc li { margin: 0.35rem 0 0; }
|
||||||
|
.help-drawer .toc label {
|
||||||
|
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
|
||||||
|
.help-drawer .more label {
|
||||||
|
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-close {
|
||||||
|
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone preview (gallery toggle) and real narrow windows */
|
||||||
|
.nav-burger { display: none; }
|
||||||
|
|
||||||
|
#vp-phone:checked ~ .app {
|
||||||
|
position: relative;
|
||||||
|
width: 390px; margin: 0.75rem auto 2rem; min-height: 760px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav {
|
||||||
|
position: absolute; top: 0; left: 0; bottom: 0;
|
||||||
|
height: auto; max-height: none;
|
||||||
|
width: min(16rem, 82%); z-index: 20;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
#vp-phone:checked ~ .app .desk-only { display: none !important; }
|
||||||
|
#vp-phone:checked ~ .app .phone-only { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .phone-list { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid .attn { order: -1; }
|
||||||
|
#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; }
|
||||||
|
#vp-phone:checked ~ .app .split,
|
||||||
|
#vp-phone:checked ~ .app .check-cols,
|
||||||
|
#vp-phone:checked ~ .app .field-pair:not(.host-type),
|
||||||
|
#vp-phone:checked ~ .app .facts,
|
||||||
|
#vp-phone:checked ~ .app .retry-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app td.subject span { max-width: none; }
|
||||||
|
#vp-phone:checked ~ .help-drawer { width: 100vw; }
|
||||||
|
|
||||||
|
.phone-only { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.app { min-height: calc(100vh - var(--gallery-h)); }
|
||||||
|
.nav {
|
||||||
|
position: fixed; top: var(--gallery-h); left: 0; bottom: 0;
|
||||||
|
z-index: 20; width: min(16rem, 84vw); max-height: none;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
.phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
.phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
.phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
.desk-only { display: none !important; }
|
||||||
|
.phone-only, .phone-list { display: block; }
|
||||||
|
.status-grid { display: flex; flex-direction: column; }
|
||||||
|
.status-grid .attn { order: -1; }
|
||||||
|
.split, .pair, #settings .split, #backup > .split,
|
||||||
|
.check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; }
|
||||||
|
.help-drawer { width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index page */
|
||||||
|
.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
|
||||||
|
.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; }
|
||||||
|
.wrap h1 b { font-weight: 600; }
|
||||||
|
.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); }
|
||||||
|
.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; }
|
||||||
|
.wrap h2 {
|
||||||
|
font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted);
|
||||||
|
}
|
||||||
|
.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; }
|
||||||
|
.note {
|
||||||
|
background: #f1eae4; border-left: 2px solid var(--accent-fill);
|
||||||
|
padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
#theme-dark:checked ~ .doc .note { background: #2a1f1b; }
|
||||||
|
.compare {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
.compare .row {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; }
|
||||||
|
.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); }
|
||||||
|
.bar .col { background: var(--nav-active-bg); color: var(--accent-text); }
|
||||||
|
.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); }
|
||||||
|
.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); }
|
||||||
|
.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } }
|
||||||
|
.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.ia li { margin: 0.2rem 0; }
|
||||||
|
.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; }
|
||||||
|
.screen-index a { display: block; padding: 0.25rem 0; }
|
||||||
|
.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; }
|
||||||
|
.icon-card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem;
|
||||||
|
}
|
||||||
|
.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; }
|
||||||
|
.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
|
||||||
|
.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; }
|
||||||
|
.mark-row figure { margin: 0; }
|
||||||
|
.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem;
|
||||||
|
background: var(--accent-fill); color: var(--on-accent); text-decoration: none;
|
||||||
|
font-weight: 600; border-radius: 5px;
|
||||||
|
}
|
||||||
|
.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); }
|
||||||
|
.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||||
|
.cta.quiet {
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Settings — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-settings" data-page="settings" data-nav="settings" data-title="Settings">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<form class="stack" action="#" onsubmit="return false">
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
<label>Username</label><input value="admin" autocomplete="username">
|
||||||
|
<label>Current password</label><input type="password" autocomplete="current-password">
|
||||||
|
<label>New password</label><input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm new password</label><input type="password">
|
||||||
|
<div class="actions-row"><button type="button">Save changes</button></div>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>DMARC aggregate reports</h2>
|
||||||
|
<p class="muted">Default <code>rua=</code> for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.</p>
|
||||||
|
<label>Default report address</label>
|
||||||
|
<input type="email" value="dmarc@mail.example.org">
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub must publish a report-authorisation record. <a href="dmarc.html">DMARC reports</a> in the panel.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org._report._dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1;</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Report authorization DNS <span class="st st-ok">ok</span></label>
|
||||||
|
<p class="muted">Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="fill g-only">
|
||||||
|
<div class="card" id="rate-limits">
|
||||||
|
<h2>Sending rate limits</h2>
|
||||||
|
<p class="muted">Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.</p>
|
||||||
|
<div class="check-cols">
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 1 — per client IP</p>
|
||||||
|
<span class="code">100 messages / 60 seconds</span>
|
||||||
|
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> / <code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — domain</p>
|
||||||
|
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — application</p>
|
||||||
|
<p class="muted">Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Create administrator — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-setup" data-page="setup" data-nav="setup" data-title="Create administrator" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Create administrator</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
|
||||||
|
<form action="status.html">
|
||||||
|
<label>Username</label>
|
||||||
|
<input autocomplete="username">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<div class="actions-row"><button type="submit">Create administrator</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/* Shared chrome for panel UI page mockups. Each screen is its own HTML file;
|
||||||
|
this script injects radios, gallery, nav, sprite, and the help drawer so
|
||||||
|
file:// viewing does not need a module fetch. */
|
||||||
|
(function () {
|
||||||
|
if (document.body.dataset.shell === "1") return;
|
||||||
|
document.body.dataset.shell = "1";
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (!main) return;
|
||||||
|
|
||||||
|
var page = document.body.getAttribute("data-page") || "";
|
||||||
|
var navKey = document.body.getAttribute("data-nav") || page;
|
||||||
|
var title = document.body.getAttribute("data-title") || document.title;
|
||||||
|
var globalOnly = document.body.getAttribute("data-global-only") === "1";
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML("afterbegin",
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-global" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-domain">' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-phone">' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-light" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-dark">' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="feat-inbound" checked>' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="nav-open">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-off" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-index">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-status">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-password">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dns">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-records">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dmarc">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-connection">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-apps">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-domain-settings">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-export">'
|
||||||
|
);
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true;
|
||||||
|
if (q.get("role") === "domain") document.getElementById("role-domain").checked = true;
|
||||||
|
if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true;
|
||||||
|
if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false;
|
||||||
|
|
||||||
|
var gallery =
|
||||||
|
'<header class="gallery">' +
|
||||||
|
'<a class="brand-mini" href="index.html">Макеты</a>' +
|
||||||
|
'<div class="seg"><span>Роль</span>' +
|
||||||
|
'<label for="role-global">Global</label>' +
|
||||||
|
'<label for="role-domain">Domain-admin</label></div>' +
|
||||||
|
'<div class="seg"><span>Ширина</span>' +
|
||||||
|
'<label for="vp-desktop">Desktop</label>' +
|
||||||
|
'<label for="vp-phone">Phone</label></div>' +
|
||||||
|
'<div class="seg"><span>Тема</span>' +
|
||||||
|
'<label for="theme-light">Light</label>' +
|
||||||
|
'<label for="theme-dark">Dark</label></div>' +
|
||||||
|
'<label class="g-only" for="feat-inbound">Inbound</label>' +
|
||||||
|
'<a href="index.html">Оглавление</a>' +
|
||||||
|
'<a href="system.html">Система</a>' +
|
||||||
|
"</header>";
|
||||||
|
|
||||||
|
var sprite =
|
||||||
|
'<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||||
|
'<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>' +
|
||||||
|
'<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></symbol>' +
|
||||||
|
'<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></symbol>' +
|
||||||
|
'<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></symbol>' +
|
||||||
|
'<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></symbol>' +
|
||||||
|
'<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></symbol>' +
|
||||||
|
'<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>' +
|
||||||
|
'<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>' +
|
||||||
|
'<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>' +
|
||||||
|
'<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>' +
|
||||||
|
'<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>' +
|
||||||
|
'<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
function icon(id) {
|
||||||
|
return '<svg class="icon"><use href="#' + id + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var navHtml =
|
||||||
|
'<nav class="nav">' +
|
||||||
|
'<a class="brand" href="status.html"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>' +
|
||||||
|
'<div class="links">' +
|
||||||
|
'<a class="n-status g-only" href="status.html">' + icon("i-status") + "Status</a>" +
|
||||||
|
'<a class="n-domains" href="domains.html">' + icon("i-domains") + "Domains</a>" +
|
||||||
|
'<a class="n-deliveries" href="deliveries.html">' + icon("i-deliveries") + "Deliveries</a>" +
|
||||||
|
'<a class="n-queue g-only" href="mail-queue.html">' + icon("i-queue") + "Mail queue</a>" +
|
||||||
|
'<a class="n-log g-only" href="system-log.html">' + icon("i-log") + "System log</a>" +
|
||||||
|
'<a class="n-inbound g-only in-only" href="inbound.html">' + icon("i-inbound") + "Inbound</a>" +
|
||||||
|
'<a class="n-dmarc g-only" href="dmarc.html">' + icon("i-dmarc") + 'DMARC <span class="tag future">1.x</span></a>' +
|
||||||
|
'<a class="n-backup g-only" href="backup.html">' + icon("i-backup") + "Backup</a>" +
|
||||||
|
'<a class="n-users g-only" href="users.html">' + icon("i-users") + "Users</a>" +
|
||||||
|
'<a class="n-help" href="help.html">' + icon("i-help") + 'Help <span class="tag future">1.x</span></a>' +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="session">' +
|
||||||
|
'<span class="session-user muted">' + icon("i-account") + "User: admin</span>" +
|
||||||
|
'<a class="n-settings" href="settings.html">' + icon("i-settings") + "Settings</a>" +
|
||||||
|
'<a class="btn-ghost" href="login.html">' + icon("i-out") + "Sign out</a>" +
|
||||||
|
"</div>" +
|
||||||
|
"</nav>";
|
||||||
|
|
||||||
|
var phone =
|
||||||
|
'<div class="phone-bar">' +
|
||||||
|
'<label class="nav-burger" for="nav-open" title="Menu">' + icon("i-menu") + "</label>" +
|
||||||
|
'<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">' +
|
||||||
|
'<span class="grow phone-title"></span>' +
|
||||||
|
'<label class="help-link" for="help-index" title="Help">?</label>' +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
var help =
|
||||||
|
'<label class="help-scrim" for="help-off"></label>' +
|
||||||
|
'<aside class="help-drawer">' +
|
||||||
|
'<label class="help-close" for="help-off">Close</label>' +
|
||||||
|
'<article class="help-pane help-pane-index">' +
|
||||||
|
"<h2>Help</h2>" +
|
||||||
|
"<p>Short notes for the card you opened — not a second copy of the guide.</p>" +
|
||||||
|
'<p class="muted">Status</p>' +
|
||||||
|
'<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>' +
|
||||||
|
'<p class="muted">Domain</p>' +
|
||||||
|
'<ul class="toc">' +
|
||||||
|
'<li><label for="help-password">New application password</label></li>' +
|
||||||
|
'<li><label for="help-dns">DNS status</label></li>' +
|
||||||
|
'<li><label for="help-records">DKIM and SPF records</label></li>' +
|
||||||
|
'<li><label for="help-dmarc">DMARC record</label></li>' +
|
||||||
|
'<li><label for="help-connection">Connection settings</label></li>' +
|
||||||
|
'<li><label for="help-apps">Applications</label></li>' +
|
||||||
|
'<li><label for="help-domain-settings">Domain settings</label></li>' +
|
||||||
|
'<li><label for="help-export">Export domain</label></li>' +
|
||||||
|
"</ul></article>" +
|
||||||
|
'<article class="help-pane help-pane-status">' +
|
||||||
|
"<h2>Status checks</h2>" +
|
||||||
|
"<p>The cards keep the readings. This drawer is what used to sit under them as paragraphs.</p>" +
|
||||||
|
"<h2>Machine</h2>" +
|
||||||
|
"<p>CPU and memory are the container’s readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>" +
|
||||||
|
"<h2>TLS certificate</h2>" +
|
||||||
|
"<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>" +
|
||||||
|
"<h2>Hostname / reverse DNS</h2>" +
|
||||||
|
"<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>" +
|
||||||
|
"<h2>Mail queue</h2>" +
|
||||||
|
"<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-password">' +
|
||||||
|
"<h2>New application password</h2>" +
|
||||||
|
"<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is lost, regenerate a new one. The previous password stops working immediately.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dns">' +
|
||||||
|
"<h2>DNS status</h2>" +
|
||||||
|
"<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few minutes — use <em>Re-check</em> after publishing.</p>" +
|
||||||
|
"<p>SPF is a shallow check: the literal address only, no <code>include:</code> or <code>redirect=</code>. Report authorization is required only when <code>rua=</code> points at a domain this server does not accept.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-records">' +
|
||||||
|
"<h2>DKIM and SPF records</h2>" +
|
||||||
|
"<p>DKIM is not a secret. The selector on this page is the one this server signs with. Merge the SPF example into an existing record if the domain already has one — do not publish a second TXT.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dmarc">' +
|
||||||
|
"<h2>DMARC record</h2>" +
|
||||||
|
"<p><code>p=none</code> does not affect delivery. Tighten to <code>p=quarantine</code> then <code>p=reject</code> once reports look clean. The report address is set under Domain settings (or the Settings default).</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-connection">' +
|
||||||
|
"<h2>Connection settings</h2>" +
|
||||||
|
"<p>Same host for every domain. Authenticate with an application login from this page. Auth is required on every port. The password is shown once at create or regenerate.</p>" +
|
||||||
|
"<p>465 is implicit TLS; 587 is STARTTLS submission when that port is enabled.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-apps">' +
|
||||||
|
"<h2>Applications</h2>" +
|
||||||
|
"<p>SASL logins for this domain. Login is unique across domains; letters, digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is shown once.</p>" +
|
||||||
|
"<p>Address mode is which From addresses this application may use: any address of the domain, or a fixed list. A trusted-IP override gives those clients a higher ceiling than the domain (still ≤ level 1) and skips the domain check; everyone else uses the domain limit if set, otherwise level 1.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-domain-settings">' +
|
||||||
|
"<h2>Domain settings</h2>" +
|
||||||
|
"<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or you override them per domain. Level 2 is an optional ceiling for all senders on this domain; it must be ≤ level 1. Application overrides live on each application.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-export">' +
|
||||||
|
"<h2>Export domain</h2>" +
|
||||||
|
"<p>The file is a secret: it carries the DKIM key and application passwords, so published DNS does not have to change on the other instance. Transfer it securely, or encrypt it as <code>.spde</code>.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
"</aside>";
|
||||||
|
|
||||||
|
var app = document.createElement("div");
|
||||||
|
app.className = "app";
|
||||||
|
app.innerHTML = navHtml + '<div class="stage">' + phone + "</div>";
|
||||||
|
main.parentNode.insertBefore(app, main);
|
||||||
|
app.insertAdjacentHTML("beforebegin", gallery + sprite);
|
||||||
|
app.querySelector(".stage").appendChild(main);
|
||||||
|
app.insertAdjacentHTML("afterend", help);
|
||||||
|
|
||||||
|
var cur = app.querySelector(".n-" + navKey);
|
||||||
|
if (cur) cur.setAttribute("aria-current", "page");
|
||||||
|
var grow = app.querySelector(".phone-title");
|
||||||
|
if (grow) grow.textContent = title;
|
||||||
|
|
||||||
|
var skip = { "index.html": 1, "system.html": 1, "app.html": 1 };
|
||||||
|
|
||||||
|
function qs() {
|
||||||
|
var p = new URLSearchParams();
|
||||||
|
if (document.getElementById("vp-phone").checked) p.set("view", "phone");
|
||||||
|
if (document.getElementById("role-domain").checked) p.set("role", "domain");
|
||||||
|
if (document.getElementById("theme-dark").checked) p.set("theme", "dark");
|
||||||
|
if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0");
|
||||||
|
var s = p.toString();
|
||||||
|
return s ? "?" + s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(href) {
|
||||||
|
if (!href) return href;
|
||||||
|
if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href;
|
||||||
|
var hash = "";
|
||||||
|
var path = href;
|
||||||
|
var hashAt = href.indexOf("#");
|
||||||
|
if (hashAt >= 0) {
|
||||||
|
hash = href.slice(hashAt);
|
||||||
|
path = href.slice(0, hashAt);
|
||||||
|
}
|
||||||
|
var qAt = path.indexOf("?");
|
||||||
|
var file = qAt >= 0 ? path.slice(0, qAt) : path;
|
||||||
|
var extra = qAt >= 0 ? path.slice(qAt + 1) : "";
|
||||||
|
var base = file.split("/").pop();
|
||||||
|
if (!base || !/\.html$/i.test(base) || skip[base]) return href;
|
||||||
|
var p = new URLSearchParams(extra);
|
||||||
|
var curQs = new URLSearchParams(qs().replace(/^\?/, ""));
|
||||||
|
["view", "role", "theme", "inbound"].forEach(function (k) {
|
||||||
|
if (curQs.has(k)) p.set(k, curQs.get(k));
|
||||||
|
else p.delete(k);
|
||||||
|
});
|
||||||
|
var s = p.toString();
|
||||||
|
return base + (s ? "?" + s : "") + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteLinks() {
|
||||||
|
document.querySelectorAll("a[href]").forEach(function (a) {
|
||||||
|
var raw = a.getAttribute("href");
|
||||||
|
if (!raw) return;
|
||||||
|
a.setAttribute("href", withQuery(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandHref() {
|
||||||
|
var brand = document.querySelector(".nav .brand");
|
||||||
|
if (!brand) return;
|
||||||
|
brand.setAttribute("href", withQuery(
|
||||||
|
document.getElementById("role-domain").checked ? "domains.html" : "status.html"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gate() {
|
||||||
|
brandHref();
|
||||||
|
if (!document.getElementById("role-domain").checked) return;
|
||||||
|
if (globalOnly) location.replace(withQuery("domains.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl() {
|
||||||
|
if (history.replaceState) {
|
||||||
|
history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash);
|
||||||
|
}
|
||||||
|
rewriteLinks();
|
||||||
|
brandHref();
|
||||||
|
gate();
|
||||||
|
}
|
||||||
|
|
||||||
|
["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("change", syncUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav a").forEach(function (a) {
|
||||||
|
a.addEventListener("click", function () {
|
||||||
|
var open = document.getElementById("nav-open");
|
||||||
|
if (open) open.checked = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rewriteLinks();
|
||||||
|
gate();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Status — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-status" data-page="status" data-nav="status" data-title="Status" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="overall">
|
||||||
|
<h2>Overall <span class="st st-warn">warn</span></h2>
|
||||||
|
<p class="muted">Running, with warnings below.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card attn" id="queue">
|
||||||
|
<h2>Mail queue <span class="st st-warn">warn</span></h2>
|
||||||
|
<p>3 Kbytes in 3 Requests.</p>
|
||||||
|
<div class="actions-row"><a class="btn" href="mail-queue.html">View queue</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="certificate">
|
||||||
|
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Expires</label>
|
||||||
|
<span class="code">2026-11-02 12:00 UTC</span>
|
||||||
|
<p class="muted">Valid for another 78 day(s).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="sockets">
|
||||||
|
<h2>Milter sockets <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>OpenDKIM</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
<tr><td>send-log</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="hostname">
|
||||||
|
<h2>Hostname and reverse DNS <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Server hostname</label>
|
||||||
|
<span class="code">mail.example.org</span>
|
||||||
|
<label>Forward and reverse lookup</label>
|
||||||
|
<span class="code">203.0.113.10 → mail.example.org</span>
|
||||||
|
<p class="muted">mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check DNS</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill in-only" id="inbound-status">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Inbound <span class="st st-warn">warn</span></h2>
|
||||||
|
<p><code>INBOUND_RELAY_ENABLE</code> is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.</p>
|
||||||
|
<p class="muted">One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.</p>
|
||||||
|
<p><a href="inbound.html">Inbound domains</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="machine">
|
||||||
|
<h2>Machine <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">CPU</td>
|
||||||
|
<td class="metric"><meter value="12" min="0" max="100" low="70" high="90" optimum="10">12%</meter> 12%</td>
|
||||||
|
<td class="muted">4 cores · 4 threads</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Memory</td>
|
||||||
|
<td class="metric"><meter value="41" min="0" max="100" low="70" high="90" optimum="10">41%</meter> 41%</td>
|
||||||
|
<td class="muted">1.6 GiB used of 4.0 GiB.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Network</td>
|
||||||
|
<td class="metric">↓ 2.0 KiB/s<br>↑ 1.0 KiB/s</td>
|
||||||
|
<td class="muted"><div>eth0: 1.0 MiB in, 512.0 KiB out</div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="processes">
|
||||||
|
<h2>Processes <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>opendkim</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 21, uptime 3 days, 4:12:01</td></tr>
|
||||||
|
<tr><td>panel</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 18, uptime 3 days, 4:12:03</td></tr>
|
||||||
|
<tr><td>postfix</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 42, uptime 3 days, 4:11:58</td></tr>
|
||||||
|
<tr><td>postfix-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>cert-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>logrotate</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="configuration">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||||
|
database and reloads both daemons. Use it if you edited the files by hand,
|
||||||
|
restored a backup, or the running configuration looks out of step with the
|
||||||
|
domain and application lists. It does not touch the mail queue or the TLS
|
||||||
|
certificate, and it is safe to run at any time.</p>
|
||||||
|
<div class="actions-row"><button type="button">Reload configuration</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>System log — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-system-log" data-page="system-log" data-nav="log" data-title="System log" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>System log</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent log entries</h2>
|
||||||
|
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy)
|
||||||
|
Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active)
|
||||||
|
Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing
|
||||||
|
Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok)
|
||||||
|
Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Panel UI system primitives.
|
||||||
|
Source of truth: system.html. Screens are separate HTML files composed from
|
||||||
|
these classes. Do not “fix” empty columns, Host/Type height, or split
|
||||||
|
actions by one-off rules — compose with these instead. */
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
width: 100%; min-width: 0;
|
||||||
|
}
|
||||||
|
.stack > .card,
|
||||||
|
.stack > .pair,
|
||||||
|
.stack > .measure,
|
||||||
|
.stack > .fill { margin-top: 0; }
|
||||||
|
.stack > .card + .card { margin-top: 0; }
|
||||||
|
|
||||||
|
.measure { width: 100%; max-width: var(--form-max); min-width: 0; }
|
||||||
|
|
||||||
|
.fill { width: 100%; max-width: var(--ops-max); min-width: 0; }
|
||||||
|
.fill > .card { margin-top: 0; }
|
||||||
|
.fill .card:has(table) { overflow-x: auto; }
|
||||||
|
.card > .measure { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Two peer jobs. One child → reading measure (domain-admin Settings).
|
||||||
|
Never a full-width lonely card on an ops page. */
|
||||||
|
.pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%; max-width: var(--ops-max); min-width: 0;
|
||||||
|
}
|
||||||
|
.pair > * { min-width: 0; }
|
||||||
|
.card > .stack { min-width: 0; }
|
||||||
|
.pair > .card { margin-top: 0; }
|
||||||
|
.pair > .card + .card { margin-top: 0; }
|
||||||
|
.pair:has(> :only-child) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.actions-row > button,
|
||||||
|
.actions-row > a.btn,
|
||||||
|
.actions-row > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Two labelled controls on one row. Labels share row 1, values share row 2
|
||||||
|
so they are the same height by construction — not by matching padding. */
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto minmax(2.5rem, auto);
|
||||||
|
column-gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.field-row > .field { display: contents; }
|
||||||
|
.field-row > .field:first-child > * { grid-column: 1; }
|
||||||
|
.field-row > .field:last-child > * { grid-column: 2; }
|
||||||
|
.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; }
|
||||||
|
.field-row > .field > :not(label) {
|
||||||
|
grid-row: 2; align-self: stretch; min-width: 0;
|
||||||
|
margin-top: 0.3rem; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code,
|
||||||
|
.field-row > .field > .code-row {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row > .field:last-child > .code {
|
||||||
|
width: fit-content; min-width: 2.75rem;
|
||||||
|
justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
.field-row .code-row {
|
||||||
|
display: flex; align-items: stretch; gap: 0.5rem; height: 100%;
|
||||||
|
}
|
||||||
|
.field-row .code-row .code {
|
||||||
|
flex: 1; min-width: 0; margin-top: 0; height: auto;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row .code-row .copy { margin-top: 0; align-self: stretch; }
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.pair { grid-template-columns: 1fr; }
|
||||||
|
/* field-row stays two columns: Type is a token, not a second form. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specimens on the system page */
|
||||||
|
.sys-wrap { max-width: 70rem; }
|
||||||
|
.sys-wrap > header p,
|
||||||
|
.sys-wrap section > p,
|
||||||
|
.sys-wrap li { max-width: 68ch; }
|
||||||
|
.sys-toc {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0; list-style: none;
|
||||||
|
}
|
||||||
|
.sys-toc a { font-size: 0.92rem; }
|
||||||
|
.specimen {
|
||||||
|
margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem;
|
||||||
|
border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.specimen > figcaption {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.specimen.bad {
|
||||||
|
border-color: var(--danger-border); background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
.specimen.bad > figcaption { color: var(--danger-fg); }
|
||||||
|
.recipes {
|
||||||
|
width: 100%; margin-top: 0.8rem; font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.recipes th, .recipes td { white-space: normal; vertical-align: top; }
|
||||||
|
.recipes code { font-size: 0.82rem; }
|
||||||
|
.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.forbid li { margin: 0.35rem 0; }
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<!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="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap sys-wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">система · не рескин</p>
|
||||||
|
<h1>Self<b>Post</b> — грамматика интерфейса</h1>
|
||||||
|
<p>Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (<code>internal/web</code>) пока не трогается.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="#regions">Регионы</a>
|
||||||
|
<a class="cta quiet" href="#controls">Контролы</a>
|
||||||
|
<a class="cta quiet" href="#recipes">Экраны</a>
|
||||||
|
<a class="cta quiet" href="status.html">Макеты экранов</a>
|
||||||
|
</div>
|
||||||
|
<ul class="sys-toc">
|
||||||
|
<li><a href="#why">Зачем</a></li>
|
||||||
|
<li><a href="#regions">Регионы</a></li>
|
||||||
|
<li><a href="#card">Карточка</a></li>
|
||||||
|
<li><a href="#controls">Контролы</a></li>
|
||||||
|
<li><a href="#actions">Действия</a></li>
|
||||||
|
<li><a href="#forbid">Нельзя</a></li>
|
||||||
|
<li><a href="#recipes">Рецепты экранов</a></li>
|
||||||
|
<li><a href="#phone">Телефон</a></li>
|
||||||
|
<li><a href="#apply">Как внедрять</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<h2>Зачем</h2>
|
||||||
|
<p>Первый макет скопировал грамматику живой панели: страница помечается <code>ops</code> или <code>form</code>, карточки стопкой, пара полей — два независимых <code>.code</code>, Save внутри <code><form></code>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в <code>panel.css</code> годами. Новый интерфейс должен <strong>не давать собрать</strong> пустую колонку, разные высоты Host/Type и кнопки на двух строках.</p>
|
||||||
|
<p>Три оси, которые больше не выбираются «на глаз» у каждой страницы:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Регион</strong> — как блок занимает ширину окна.</li>
|
||||||
|
<li><strong>Карточка</strong> — заголовок, справка, тело, действия.</li>
|
||||||
|
<li><strong>Контрол</strong> — одно поле или пара полей одной высоты.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="regions">
|
||||||
|
<h2>Регионы</h2>
|
||||||
|
<p>Страница — это <code>stack</code> регионов, не класс на <code><main></code>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Регион</th><th>Ширина</th><th>Когда</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>measure</code></td>
|
||||||
|
<td>до 42rem, влево</td>
|
||||||
|
<td>Одна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pair</code></td>
|
||||||
|
<td>две колонки до 90rem</td>
|
||||||
|
<td>Две равноправные задачи на одном экране. Один ребёнок — сам сжимается в <code>measure</code> (domain-admin без DMARC).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>fill</code></td>
|
||||||
|
<td>до 90rem</td>
|
||||||
|
<td>Таблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>stack</code></td>
|
||||||
|
<td>колонка с зазором</td>
|
||||||
|
<td>Вертикальный порядок регионов. Зазор даёт <code>gap</code>, не <code>.card + .card</code>.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<figure class="specimen bad">
|
||||||
|
<figcaption>Нельзя — страница-форма, две задачи стопкой</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Карточка узкая. Справа пустое поле на 1600px.</p>
|
||||||
|
<button type="button">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Вторая задача под первой — та же пустота.</p>
|
||||||
|
<button type="button">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Надо — pair</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.</p>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Тот же экран, вторая задача. Не «ещё одна форма ниже».</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="card">
|
||||||
|
<h2>Карточка</h2>
|
||||||
|
<p>Один хром: <code>card-head</code> (заголовок + слот «?»), тело, при необходимости <code>actions-row</code>. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.</p>
|
||||||
|
<p>Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Хром карточки</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<span class="help-link" title="What these checks mean">?</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Чтение и Re-check здесь. Абзац «зачем MX» — в справке.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="#forbid">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="controls">
|
||||||
|
<h2>Контролы</h2>
|
||||||
|
<p>Одно поле — <code>field</code> (подпись + контроль). Два поля в ряд — <code>field-row</code>: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (<code>b0ebe06</code>) из этой разметки не собирается.</p>
|
||||||
|
<p><code>field-row</code> с узкой второй колонкой — Host / name ‖ Type. <code>field-row equal</code> — два равноправных инпута (лимит и окно).</p>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row — Host ‖ Type, с Copy и без</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>With Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Lookup, no Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row equal — лимит ‖ окно</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Level-2 rate limit</h2>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="actions">
|
||||||
|
<h2>Действия</h2>
|
||||||
|
<p>Primary, secondary и danger — всегда <code>actions-row</code>. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут <code>form=</code>. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Save и Delete в одном ряду</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Edit user</h2>
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="#forbid">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="forbid">
|
||||||
|
<h2>Нельзя</h2>
|
||||||
|
<p>Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.</p>
|
||||||
|
<ul class="forbid">
|
||||||
|
<li>Класс на <code>main</code> (<code>ops</code> / <code>form</code>) как способ выбрать ширину. Ширину выбирает регион.</li>
|
||||||
|
<li>Одиночная карточка на 90rem с двумя полями ввода. Это <code>measure</code> или <code>pair</code>.</li>
|
||||||
|
<li>Danger zone отдельным <code>fill</code>. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).</li>
|
||||||
|
<li>Два <code>.code</code> рядом с разным padding, чтобы «почти совпало». Только <code>field-row</code>.</li>
|
||||||
|
<li>Submit внутри <code><form></code>, danger-ссылка после <code></form></code>.</li>
|
||||||
|
<li>Перенос однострочного поля, адреса, статуса, hostname. <code>nowrap</code> + горизонтальный скролл карточки.</li>
|
||||||
|
<li>Status из одних бейджей без Detail / без строки очереди / без PTR.</li>
|
||||||
|
<li>Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; <code>pair</code> → одна колонка; <code>field-row</code> остаётся парой.</li>
|
||||||
|
<li>Копировать разметку <code>internal/web/view/templates</code> «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="recipes">
|
||||||
|
<h2>Рецепты экранов</h2>
|
||||||
|
<p>Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Экран</th><th>Стек регионов</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="login.html">Login</a> / <a href="setup.html">setup</a></td>
|
||||||
|
<td>центр оболочки, <code>measure</code> 24rem, полный stamp</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="status.html">Status</a></td>
|
||||||
|
<td><code>stack</code>: overall → <code>pair</code> очередь‖TLS → <code>pair</code> milters‖PTR → inbound если есть → <code>pair</code> machine‖processes → configuration. На карточках остаются числа и Detail.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domains.html">Domains</a> / <a href="inbound.html">Inbound</a> / <a href="deliveries.html">Deliveries</a> / <a href="system-log.html">log</a> / <a href="mail-queue.html">queue</a></td>
|
||||||
|
<td><code>fill</code> таблица. Добавление домена — поле в той же карточке списка (<code>measure</code> внутри, не вторая карточка и не инпут на 90rem).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">Domain</a></td>
|
||||||
|
<td><code>fill</code> DNS status → <code>pair</code> DKIM/SPF ‖ DMARC → <code>pair</code> connection ‖ add app → <code>fill</code> applications → <code>pair</code> domain settings (две половины) → <code>pair</code> export ‖ danger. «?» на рабочих карточках.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">Inbound domain</a></td>
|
||||||
|
<td><code>fill</code> MX DNS → <code>pair</code> upstream ‖ MX to publish → <code>pair</code> recipients ‖ danger</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="dmarc.html">DMARC</a></td>
|
||||||
|
<td>hub: <code>pair</code> ingest ‖ this week, затем <code>fill</code> список отчётов. Домен: <a href="dmarc-domain.html">roll-up</a> (<code>pair</code> 7 days ‖ third-party, <code>fill</code> reports + sources). Один XML: <a href="dmarc-report.html">просмотр</a> — <code>pair</code> report ‖ policy, <code>fill</code> records. Не дашборд, не <code>ruf=</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="backup.html">Backup</a></td>
|
||||||
|
<td><code>pair</code> full backup ‖ import</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="settings.html">Settings</a></td>
|
||||||
|
<td>global: <code>pair</code> credentials ‖ DMARC, затем <code>fill</code> rate limits. Domain-admin: один ребёнок в <code>pair</code> → сам <code>measure</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="users.html">Users</a></td>
|
||||||
|
<td><code>fill</code> таблица. <a href="user-form.html">Create/Edit</a> — <code>measure</code> + <code>actions-row</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain-delete.html">Confirm delete</a></td>
|
||||||
|
<td><code>measure</code> одна карточка</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="phone">
|
||||||
|
<h2>Телефон</h2>
|
||||||
|
<p>Аварийный доступ, не продукт. <code>pair</code> складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). <code>field-row</code> не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="apply" style="border-bottom:none">
|
||||||
|
<h2>Как внедрять</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Новый экран или правка макета — только классы из <code>system.css</code> (<code>stack</code>, <code>pair</code>, <code>measure</code>, <code>fill</code>, <code>field-row</code>, <code>actions-row</code>).</li>
|
||||||
|
<li>Не добавлять исключения в <code>mock.css</code> «для этой страницы», если это ширина, высота пары полей или ряд кнопок.</li>
|
||||||
|
<li>Click-through — отдельные страницы (<code>status.html</code>, <code>domain.html</code>, …), не простыня с якорями. Оболочка общая: <code>shell.js</code>.</li>
|
||||||
|
<li>Вёрстка <code>internal/web</code> — отдельная задача после утверждения системы, не параллельный рескин шаблонов.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="muted">Классы живут в <code>docs/assets/panel-ui/system.css</code>. Этот файл — спецификация. Экраны — <a href="status.html">status.html</a> и соседние страницы; оглавление — <a href="index.html">index.html</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete ops-alerts — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-delete" data-page="user-delete" data-nav="users" data-title="Delete user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete ops-alerts</h1>
|
||||||
|
<a class="back" href="user-form.html">← Back to ops-alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete ops-alerts</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Edit user — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-form" data-page="user-form" data-nav="users" data-title="Edit user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Edit user</h1>
|
||||||
|
<a class="back" href="users.html">← Back to users</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<label>Password (leave empty to keep)</label>
|
||||||
|
<input type="password">
|
||||||
|
<label>Role</label>
|
||||||
|
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Assigned domains</legend>
|
||||||
|
<p class="muted">Required for domain administrators.</p>
|
||||||
|
<label class="check"><input type="checkbox"> example.com</label>
|
||||||
|
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="user-delete.html">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Users — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-users" data-page="users" data-nav="users" data-title="Users" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<p><a href="user-form.html">Create user</a></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -86,6 +86,14 @@
|
|||||||
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
|
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Positioning</h2>
|
||||||
|
<div class="note"><b>Stamp subtitle:</b> SELF-HOSTED SMTP RELAY (unchanged on the mark).<br>
|
||||||
|
<b>Tagline:</b> YOUR SMTP. YOUR RULES.<br>
|
||||||
|
<b>Hero line (README, GitHub):</b> Self-hosted SMTP relay with a web panel — one Docker image, no provider.<br>
|
||||||
|
Canonical copy also lives in <code>docs/product.md</code> § Positioning.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Эталон</h2>
|
<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">
|
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
|
||||||
@@ -121,7 +129,7 @@
|
|||||||
<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>
|
<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>
|
</svg>
|
||||||
<h3>SelfPost</h3>
|
<h3>SelfPost</h3>
|
||||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
<p>Self-hosted SMTP relay with a web panel — one Docker image, no provider.</p></div>
|
||||||
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
|
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
|
||||||
<div><div class="gh dk">
|
<div><div class="gh dk">
|
||||||
<div class="bc">mixeme / <b>selfpost</b></div>
|
<div class="bc">mixeme / <b>selfpost</b></div>
|
||||||
@@ -135,7 +143,7 @@
|
|||||||
<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>
|
<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>
|
</svg>
|
||||||
<h3>SelfPost</h3>
|
<h3>SelfPost</h3>
|
||||||
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
|
<p>Self-hosted SMTP relay with a web panel — one Docker image, no provider.</p></div>
|
||||||
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
|
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+187
-22
@@ -1,8 +1,9 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,16 +12,46 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
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.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
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),
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
not duplicated here.
|
not duplicated here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authorship and disclosure
|
||||||
|
|
||||||
|
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
|
||||||
|
a maintainer's direction, and the project says so rather than hiding it.
|
||||||
|
|
||||||
|
Concretely, this is what "says so" means, and none of it is an oversight to be
|
||||||
|
tidied away later:
|
||||||
|
|
||||||
|
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
|
||||||
|
the ~140 commits that predate v1.0.
|
||||||
|
- The model routing table below is public, in a file the README links to.
|
||||||
|
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
|
||||||
|
the repository.
|
||||||
|
- Process notes written for an agent — "after a context reset, pick an item
|
||||||
|
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
|
||||||
|
|
||||||
|
**Why not quietly drop it.** Once the trailers are in the history, removing the
|
||||||
|
routing table or the rules file would not conceal authorship, it would only
|
||||||
|
make the project look like it was trying to. Partial concealment reads worse
|
||||||
|
than the plain statement, and the plain statement costs nothing: the code is
|
||||||
|
reviewed, tested, and shipped under the same rules either way, and the
|
||||||
|
[security design](security.md) records what was audited and what was accepted.
|
||||||
|
|
||||||
|
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
|
||||||
|
obligation — not because the convention around AI authorship shifts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Model routing
|
## Model routing
|
||||||
|
|
||||||
| Kind of work | Model | Examples |
|
| Kind of work | Model | Examples |
|
||||||
@@ -33,6 +64,66 @@ not duplicated here.
|
|||||||
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||||
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||||
|
|
||||||
|
**Composer → Opus → Fable** (when a plan specifies it): **Composer** writes all
|
||||||
|
implementation for that item (code, tests, docs); **Opus** does the technical
|
||||||
|
review of the result; **Fable** does the security review. A plan's checklist
|
||||||
|
overrides the default routing table for that item only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan checklists
|
||||||
|
|
||||||
|
Open [roadmap.md](roadmap.md) items with a linked plan file carry an
|
||||||
|
**Implementation checklist** in [plans/](plans/). After a context reset, work
|
||||||
|
the **active** plan — not the roadmap prose — from the first unchecked line.
|
||||||
|
|
||||||
|
**Checklist format**
|
||||||
|
|
||||||
|
- `- [ ] Step description — **Model**` (model from the routing table above).
|
||||||
|
- Mark `[x]` only in the commit that actually finishes the step.
|
||||||
|
- `candidate` items: checklist may exist, but no code until the roadmap status
|
||||||
|
is agreed.
|
||||||
|
|
||||||
|
**Progress column** in the roadmap index (`0/N`) counts every checklist line in
|
||||||
|
that plan (including docs and `go vet` / `go test` steps). Update `N` when the
|
||||||
|
checklist changes; update the numerator when steps are checked off.
|
||||||
|
|
||||||
|
### Roadmap track: commit and version
|
||||||
|
|
||||||
|
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
|
||||||
|
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
|
||||||
|
|
||||||
|
**After each checklist step**
|
||||||
|
|
||||||
|
1. Check off the step in the plan file; bump `Progress` in the roadmap index.
|
||||||
|
2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step.
|
||||||
|
3. Commit on `main` (push / tag / Release only on explicit request).
|
||||||
|
|
||||||
|
**After each roadmap stage** (all checklist steps done; «Done when» satisfied)
|
||||||
|
|
||||||
|
1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a
|
||||||
|
fresh `[Unreleased]`, bump the image pin in
|
||||||
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
|
||||||
|
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||||
|
[§ Release image](#release-image)).
|
||||||
|
|
||||||
|
**Planned version cuts** (from pin `1.9.1`; adjust if semver changes mid-track):
|
||||||
|
|
||||||
|
| Stage | ID | Cut |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | queue-retries | `1.3.1` (PATCH) |
|
||||||
|
| 2 | inbound-relay | `1.4.0` |
|
||||||
|
| 3 | send-log-retention | `1.5.0` |
|
||||||
|
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
|
||||||
|
| 5 | dmarc-reports | `1.7.0` |
|
||||||
|
| 6 | panel-docs | `1.8.0` |
|
||||||
|
| 7 | application auth IPs | `1.9.0` |
|
||||||
|
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
|
||||||
|
| 9 | inbound-antispam-panel | `1.10.0` (planned) |
|
||||||
|
|
||||||
|
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||||
|
commit rule but **no** version cut until the next product stage ships.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Technology stack and tools
|
## Technology stack and tools
|
||||||
@@ -61,8 +152,10 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
|
|||||||
|
|
||||||
## External libraries
|
## External libraries
|
||||||
|
|
||||||
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must
|
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||||||
be permissive or GPL-family (see
|
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
|
||||||
|
`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go
|
||||||
|
dependencies must be permissive or GPL-family (see
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
|
|
||||||
### Main module (`go.mod`)
|
### Main module (`go.mod`)
|
||||||
@@ -76,6 +169,13 @@ be permissive or GPL-family (see
|
|||||||
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
||||||
the tree are AGPL-3.0-compatible.
|
the tree are AGPL-3.0-compatible.
|
||||||
|
|
||||||
|
### Vendored front-end
|
||||||
|
|
||||||
|
| Asset | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
|
||||||
|
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
|
||||||
|
|
||||||
### E2e module (`test/e2e/go.mod`)
|
### E2e module (`test/e2e/go.mod`)
|
||||||
|
|
||||||
| Package | Version | Repository | License |
|
| Package | Version | Repository | License |
|
||||||
@@ -90,6 +190,10 @@ the image.
|
|||||||
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||||
from Debian bookworm repositories; licenses are in each package's `copyright`
|
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||||
file on <https://packages.debian.org/bookworm/>.
|
file on <https://packages.debian.org/bookworm/>.
|
||||||
|
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
|
||||||
|
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
|
||||||
|
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
|
||||||
|
the OFL text at `/static/OFL.txt`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,7 +205,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||||
make build VERSION=1.0.0
|
make build VERSION=1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
@@ -122,8 +226,10 @@ 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
|
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
|
||||||
runtime stage (Debian + mail stack). See [architecture.md](architecture.md) §
|
runtime stage (Debian + mail stack). Runtime config and scripts use `COPY
|
||||||
Image and processes.
|
--chmod` so file modes in the image do not depend on how the build context was
|
||||||
|
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
|
||||||
|
See [architecture.md](architecture.md) § Image and processes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,9 +251,12 @@ tag / push only on explicit request (see `release.yml`).
|
|||||||
|
|
||||||
### Release image
|
### Release image
|
||||||
|
|
||||||
The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
The release image is published **only** for a SemVer version `X.Y.Z`: a
|
||||||
`main`). The tag is the single source of version: it drives the image tag and
|
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
|
||||||
`-ldflags` in the binaries so they cannot drift apart.
|
that supplies that version. Pushing a git tag alone does not publish. Ordinary
|
||||||
|
commits, and a dispatch from `main` without a version input, do not publish.
|
||||||
|
The version is the single source that drives the image tag and `-ldflags` in
|
||||||
|
the binaries so they cannot drift apart.
|
||||||
|
|
||||||
**Steps (on explicit request):**
|
**Steps (on explicit request):**
|
||||||
|
|
||||||
@@ -155,8 +264,42 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
|||||||
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||||
local-trial image references) in the **same** release commit.
|
local-trial image references) in the **same** release commit.
|
||||||
2. Create and push git tag `vX.Y.Z` on that commit.
|
2. Create and push git tag `vX.Y.Z` on that commit.
|
||||||
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
|
||||||
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||||
|
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
|
||||||
|
|
||||||
|
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
|
||||||
|
page lists only **published** releases. A draft is visible to maintainers only —
|
||||||
|
it looks like “no releases” to everyone else. CI does not create or publish the
|
||||||
|
GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub
|
||||||
|
(or re-pushing tags while cleaning the registry) converts a published release
|
||||||
|
back into a **draft** — that matches “I published three times and it keeps
|
||||||
|
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
|
||||||
|
GHCR package versions, not the git tag.
|
||||||
|
|
||||||
|
Push workflow and source changes to **github.com/mixeme/selfpost** before
|
||||||
|
publishing — Actions reads that repo, not Gitea.
|
||||||
|
|
||||||
|
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
|
||||||
|
GitHub, two things follow:
|
||||||
|
|
||||||
|
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
|
||||||
|
prune remote tags that are absent on Gitea (or re-push with `--force` /
|
||||||
|
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
|
||||||
|
Release back to draft. Mirror **branches and new tags forward**; do not
|
||||||
|
delete release tags on the GitHub side. GHCR cleanup is package versions in
|
||||||
|
the UI — not `git push github --delete` and not tag prune on the mirror.
|
||||||
|
|
||||||
|
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
|
||||||
|
still points at a commit whose `release.yml` has `on: push: tags` and no
|
||||||
|
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
|
||||||
|
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
|
||||||
|
`release.yml` on **Publish release** (`release: published`), so mirroring
|
||||||
|
those tags alone does not start the image build.
|
||||||
|
|
||||||
|
Safe mirror: push tags to GitHub without deleting existing ones; keep release
|
||||||
|
tags on Gitea; publish the GitHub Release on github.com after the mirror has
|
||||||
|
the tag.
|
||||||
|
|
||||||
Ordinary commits **do not** publish an image. The compose pin and the git tag
|
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
|
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
|
||||||
@@ -223,7 +366,11 @@ operator would actually use.
|
|||||||
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
|
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
|
||||||
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
|
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
|
||||||
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
|
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
|
||||||
`docker restart`). Polling with timeouts only — no fixed `sleep`.
|
`docker restart`); startup checks that supervisord actually brought up
|
||||||
|
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
|
||||||
|
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
|
||||||
|
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
|
||||||
|
Polling with timeouts only — no fixed `sleep`.
|
||||||
|
|
||||||
Requires **Docker + Compose v2** on the machine running the suite.
|
Requires **Docker + Compose v2** on the machine running the suite.
|
||||||
|
|
||||||
@@ -238,21 +385,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
|
|||||||
|
|
||||||
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
|
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
|
||||||
|
|
||||||
### `release.yml` — push of tag `vX.Y.Z` or `workflow_dispatch`
|
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
|
||||||
|
|
||||||
|
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
|
||||||
|
same pattern as gosentry / imap-scrub). You can also run it manually via
|
||||||
|
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
|
||||||
|
run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
|
||||||
|
|
||||||
|
`prepare` takes the version from `github.event.release.tag_name` on a release
|
||||||
|
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
|
||||||
|
is missing or not `X.Y.Z` fails in `prepare`.
|
||||||
|
|
||||||
```
|
```
|
||||||
prepare (version from tag)
|
release: published
|
||||||
|
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
|
||||||
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
|
||||||
→ docker build --load (VERSION from tag)
|
→ docker build --load (VERSION from prepare)
|
||||||
→ e2e (test/e2e)
|
→ e2e (test/e2e)
|
||||||
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
|
||||||
→ merge
|
→ merge
|
||||||
→ docker buildx imagetools create → unified manifest X.Y.Z
|
→ docker buildx imagetools create → unified manifest X.Y.Z
|
||||||
|
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
|
||||||
```
|
```
|
||||||
|
|
||||||
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
|
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
|
emulation for e2e is impractical. E2e first, then push — the registry receives
|
||||||
the bytes that passed the gate.
|
the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
|
||||||
|
tagged in GHCR; per-arch names exist briefly during the merge job.
|
||||||
|
|
||||||
A failed e2e **blocks** image publication.
|
A failed e2e **blocks** image publication.
|
||||||
|
|
||||||
@@ -273,7 +432,9 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
|
||||||
|
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
@@ -282,7 +443,9 @@ There is no `docs/archive/` directory.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
| [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 |
|
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||||
|
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
|
||||||
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||||
|
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
|
||||||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
| [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) |
|
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||||
@@ -299,6 +462,8 @@ Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
|||||||
fails on an undocumented `loadConfig` or build-script key.
|
fails on an undocumented `loadConfig` or build-script key.
|
||||||
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||||
silent drive-by edits.
|
silent drive-by edits.
|
||||||
|
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
|
||||||
|
same change as a new `internal/store/migrations/*.sql` file.
|
||||||
|
|
||||||
### Verifying docs against code
|
### Verifying docs against code
|
||||||
|
|
||||||
@@ -311,7 +476,7 @@ to prose.
|
|||||||
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||||
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||||
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
| 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) |
|
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/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) |
|
| 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) |
|
| 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) |
|
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||||
|
|||||||
+776
-223
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,253 @@
|
|||||||
|
# Plan: inbound-antispam-panel
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Version:** `1.10.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the operator a **panel view of inbound anti-spam decisions** and **editable
|
||||||
|
allow/deny lists** that tune filter behaviour — without SelfPost shipping or
|
||||||
|
starting an anti-spam engine ([product.md](../product.md) out-of-scope list
|
||||||
|
unchanged).
|
||||||
|
|
||||||
|
Typical journal row:
|
||||||
|
|
||||||
|
| When | From | To | Subject | Decision |
|
||||||
|
|------|------|----|---------|----------|
|
||||||
|
| 2026-08-19 09:14 | `spammer@evil.example` | `user@inbound.example` | Cheap pills | reject (rspamd: BAYES_SPAM +0.95) |
|
||||||
|
|
||||||
|
Lists let the operator correct false positives/negatives (e.g. always allow
|
||||||
|
`billing@vendor.example`, block `192.0.2.0/24`).
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
- Inbound relay ships in `[1.4.0]` (`INBOUND_RELAY_ENABLE`); optional
|
||||||
|
`INBOUND_ANTISPAM_MILTER` attaches an **external** filter (documented rspamd
|
||||||
|
sidecar in [deploy/antispam/docker-compose.antispam.yml](../../deploy/antispam/docker-compose.antispam.yml)).
|
||||||
|
- The inbound smtpd has **no journal-milter** today — only outbound 465/587
|
||||||
|
writes `send_log` ([architecture.md](../architecture.md)).
|
||||||
|
- Inbound UI and handlers are **global-administrator only** today
|
||||||
|
(`requireInbound` → `requireGlobal`).
|
||||||
|
|
||||||
|
## Quarantine
|
||||||
|
|
||||||
|
**Not in this plan.** Held-mail review/release is tracked separately as roadmap
|
||||||
|
candidate [inbound-quarantine](inbound-quarantine.md). The journal may still
|
||||||
|
record a `quarantine` **decision label** when the external filter reports it,
|
||||||
|
without SelfPost storing the message.
|
||||||
|
|
||||||
|
**DMARC `p=quarantine`** remains unrelated (outbound policy for receivers).
|
||||||
|
|
||||||
|
## Decisions (closed 2026-08-19)
|
||||||
|
|
||||||
|
### Capture mechanism
|
||||||
|
|
||||||
|
**Inbound journal-milter in `panel` only** — no rspamd history API polling in v1.
|
||||||
|
A separate unix socket (`INBOUND_JOURNAL_MILTER_SOCKET`, default
|
||||||
|
`/run/selfpost/inbound-journal.sock`) on the inbound smtpd only; fail-open
|
||||||
|
(`default_action=accept`).
|
||||||
|
|
||||||
|
**Milter order on port 25** (when antispam is enabled):
|
||||||
|
|
||||||
|
```text
|
||||||
|
smtpd_milters = { antispam, inbound-journal }
|
||||||
|
```
|
||||||
|
|
||||||
|
Antispam runs first; inbound-journal runs at end-of-message on the **accept**
|
||||||
|
path and reads rspamd-added headers (`X-Rspamd-Action`, `X-Rspamd-Score`, symbol
|
||||||
|
summary when present).
|
||||||
|
|
||||||
|
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also
|
||||||
|
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
|
||||||
|
lines and inserts journal rows (client IP, envelope from/to when logged, reason
|
||||||
|
text, subject empty if rejected before DATA). DMARC-ingest pipe traffic is out
|
||||||
|
of scope for this tailer.
|
||||||
|
|
||||||
|
### Journal without `INBOUND_ANTISPAM_MILTER`
|
||||||
|
|
||||||
|
Journal stays **on** whenever `INBOUND_RELAY_ENABLE=true` (not tied to the
|
||||||
|
antispam hook). Without an external filter:
|
||||||
|
|
||||||
|
- **accept** rows — message relayed (engine `selfpost`, detail empty);
|
||||||
|
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
|
||||||
|
size limit, unknown recipient, etc.).
|
||||||
|
|
||||||
|
Panel copy explains that rspamd symbols appear only when the antispam milter is
|
||||||
|
configured.
|
||||||
|
|
||||||
|
### RBAC
|
||||||
|
|
||||||
|
**Global administrator only** for journal and lists — same as inbound relay. No
|
||||||
|
domain-admin access in v1.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
Mirror send-log retention:
|
||||||
|
|
||||||
|
- Settings key `inbound_spam_log_retention_days` on `/settings` (global admin).
|
||||||
|
- Default **90** days; validation **7–365**; seed from env
|
||||||
|
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
|
||||||
|
- Prune every **6 hours** (same cadence as send-log retention).
|
||||||
|
- Hard cap **10 000** rows after age prune (drop oldest) — busier than DMARC
|
||||||
|
reports, lighter than unbounded growth.
|
||||||
|
|
||||||
|
### Allow/deny lists
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Entry types | sender address, sender domain (`@domain` suffix), client IP / CIDR |
|
||||||
|
| Precedence | **deny overrides allow** |
|
||||||
|
| Scope | instance-wide (not per inbound domain in v1) |
|
||||||
|
| Where applied | **rspamd map files** under `/data/antispam/` (allow.map, deny.map); documented compose fragment mounts them into the sidecar |
|
||||||
|
| SelfPost pre-check | **out of v1** — lists live in rspamd only |
|
||||||
|
| Edit access | global administrator only |
|
||||||
|
| When inactive | lists editable in the panel but UI warns that sync applies only when `INBOUND_ANTISPAM_MILTER` is set; `postfix reload` / rspamd reload documented in guide |
|
||||||
|
|
||||||
|
List change → atomic map rewrite (same pattern as Postfix maps) → rspamd reload
|
||||||
|
via documented operator step or sidecar `SIGHUP` in the compose fragment.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
| Condition | Behaviour |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `INBOUND_RELAY_ENABLE=false` | no inbound journal milter, no UI, no tables written |
|
||||||
|
| inbound on, antispam empty | journal + UI; lists visible but marked inactive for filter sync |
|
||||||
|
| inbound on + antispam set | full journal (symbols on accept) + list sync |
|
||||||
|
|
||||||
|
No separate feature flag beyond inbound relay + existing antispam env vars.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
New nav item **Inbound spam** (or subsection under **Inbound**) — journal table
|
||||||
|
with filters (decision, domain, date range), list management on the same page or
|
||||||
|
a tab. Mockup: add `docs/assets/panel-ui/inbound_spam.html` in the panel step.
|
||||||
|
|
||||||
|
## Schema (migration `0011_inbound_spam_log.sql`)
|
||||||
|
|
||||||
|
**`inbound_spam_log`**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `inbound_domain` | TEXT NOT NULL | recipient domain (from `relay_domains`) |
|
||||||
|
| `client_ip` | TEXT NOT NULL | |
|
||||||
|
| `from_addr` | TEXT NOT NULL | envelope from |
|
||||||
|
| `to_addr` | TEXT NOT NULL | envelope to (one row per recipient) |
|
||||||
|
| `subject` | TEXT NOT NULL | truncated with `mailhdr.SubjectMaxRunes` (200) |
|
||||||
|
| `decision` | TEXT NOT NULL | `accept`, `reject`, `tempfail`, `quarantine` |
|
||||||
|
| `engine` | TEXT NOT NULL | `rspamd`, `postfix`, `selfpost` |
|
||||||
|
| `detail` | TEXT NOT NULL | symbol/score summary or log-line reason; may be empty |
|
||||||
|
| `created_at` | TEXT NOT NULL | RFC3339 UTC |
|
||||||
|
|
||||||
|
Index on `(created_at)`, `(inbound_domain, created_at)`.
|
||||||
|
|
||||||
|
**`inbound_spam_list`**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `list_type` | TEXT NOT NULL | `allow` or `deny` |
|
||||||
|
| `entry_type` | TEXT NOT NULL | `address`, `domain`, `ip` |
|
||||||
|
| `value` | TEXT NOT NULL | normalized entry |
|
||||||
|
| `note` | TEXT NOT NULL | operator comment; default `''` |
|
||||||
|
| `created_at` | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
Unique on `(list_type, entry_type, value)`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Schema, store CRUD, retention loop, mail.log tailer extension for inbound
|
||||||
|
rejects.
|
||||||
|
- Inbound journal-milter (fail-open) + Postfix `postfix-config.sh` wiring.
|
||||||
|
- rspamd map sync under `/data/antispam/`.
|
||||||
|
- Panel journal + list UI (global admin).
|
||||||
|
- Full backup includes `/data/antispam/` and new SQLite tables.
|
||||||
|
- Tests; [guide.md](../guide.md); [architecture.md](../architecture.md).
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Shipping rspamd, ClamAV, or any filter binary inside the SelfPost image.
|
||||||
|
- Outbound spam filtering.
|
||||||
|
- [inbound-quarantine.md](inbound-quarantine.md) storage/release.
|
||||||
|
- rspamd rule editing, Bayes training UI, antivirus.
|
||||||
|
- MIME replay from the panel (metadata only).
|
||||||
|
- Per-domain lists; domain-admin RBAC.
|
||||||
|
- rspamd history API polling.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
internet["Internet :25"] --> inbound["Postfix inbound smtpd"]
|
||||||
|
inbound --> antispam["External antispam milter optional"]
|
||||||
|
antispam --> journalM["inbound-journal milter fail-open"]
|
||||||
|
journalM --> upstream["smtp upstream transport"]
|
||||||
|
journalM --> sqlite["inbound_spam_log"]
|
||||||
|
mailog["mail.log tailer"] --> sqlite
|
||||||
|
listsUI["Panel allow/deny lists"] --> maps["/data/antispam/*.map"]
|
||||||
|
maps --> antispam
|
||||||
|
sqlite --> journalUI["Panel inbound spam journal"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Same validation whitelists as inbound relay ([security.md](../security.md)).
|
||||||
|
- Journal rows contain PII — retention and global-admin-only access.
|
||||||
|
- Inbound journal milter fail-open must not block mail (backup-MX role).
|
||||||
|
- List map writes injection-safe; deny wins over allow.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- With inbound relay + antispam sidecar, panel shows accept rows with symbols
|
||||||
|
and reject rows from mail.log tailing.
|
||||||
|
- Operator can add/remove list entries; a test message reflects allow/deny.
|
||||||
|
- With antispam hook off, journal still shows Postfix-level decisions; list UI
|
||||||
|
shows inactive sync notice.
|
||||||
|
- `go vet`, `go test`, image build green; guide and architecture updated.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- rspamd header/symbol format drift — thin parser, pin sidecar tag in compose.
|
||||||
|
- Journal volume — retention + 10k cap.
|
||||||
|
- mail.log parse brittleness — unit tests on sample lines; inbound smtpd only.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Inbound relay, shipped in `[1.4.0]` — as built in
|
||||||
|
[architecture.md](../architecture.md) § Mail path. Its plan file was removed
|
||||||
|
once it shipped.
|
||||||
|
|
||||||
|
## Model routing (this plan)
|
||||||
|
|
||||||
|
| Role | Model |
|
||||||
|
|------|-------|
|
||||||
|
| All implementation (code, tests, docs) | **Composer** |
|
||||||
|
| Technical review (reviewer ≠ author) | **Opus** |
|
||||||
|
| Security review | **Fable** |
|
||||||
|
|
||||||
|
Composer ships each step; Opus reviews the accumulated diff once implementation
|
||||||
|
steps are done (fix loop: Composer addresses Opus findings, Opus re-checks).
|
||||||
|
Fable runs after Opus sign-off.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.10.0`** (MINOR). One commit per step;
|
||||||
|
[development.md](../development.md) § Plan checklists.
|
||||||
|
|
||||||
|
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
|
||||||
|
- [ ] Migration `0011_inbound_spam_log.sql` — **Composer**
|
||||||
|
- [ ] Inbound journal-milter + Postfix wiring — **Composer**
|
||||||
|
- [ ] mail.log tailer: inbound reject rows — **Composer**
|
||||||
|
- [ ] List CRUD + validation + atomic rspamd map sync — **Composer**
|
||||||
|
- [ ] Panel: journal + lists UI (+ mockup) — **Composer**
|
||||||
|
- [ ] Backup includes `/data/antispam/` — **Composer**
|
||||||
|
- [ ] Unit + handler tests — **Composer**
|
||||||
|
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
|
||||||
|
- [ ] `go vet`, `go test`, e2e if applicable — **Composer**
|
||||||
|
- [ ] Technical review of inbound antispam changes — **Opus**
|
||||||
|
- [ ] Security review inbound antispam path — **Fable**
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Plan: inbound-quarantine
|
||||||
|
|
||||||
|
**Status:** candidate — design TBD; needs explicit agreement before any code
|
||||||
|
**Version:** TBD (`1.x` MINOR if opt-in and backward-compatible; may warrant `2.x`
|
||||||
|
if it changes inbound storage or backup contract).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal (draft)
|
||||||
|
|
||||||
|
Let the operator **hold suspicious inbound mail** instead of only accept/reject
|
||||||
|
at the milter — review in the panel, then release to the configured upstream,
|
||||||
|
delete, or add sender to allow/deny lists.
|
||||||
|
|
||||||
|
This is a **product boundary question**, not a settled design. The item exists
|
||||||
|
so we can compare approaches before committing.
|
||||||
|
|
||||||
|
## Why it is separate from inbound-antispam-panel
|
||||||
|
|
||||||
|
[inbound-antispam-panel.md](inbound-antispam-panel.md) covers **metadata**
|
||||||
|
(journal + lists). Quarantine implies **storing message bodies** (or a durable
|
||||||
|
reference to them) under `/data`, retention, release workflow, and a wider
|
||||||
|
attack/backup surface — closer to "mini-mailbox" than pure relay.
|
||||||
|
|
||||||
|
## Open questions (to decide before a checklist)
|
||||||
|
|
||||||
|
1. **Where mail lives**
|
||||||
|
- SelfPost store under `/data/quarantine/` (panel owns lifecycle), or
|
||||||
|
- delegate to rspamd/Redis and panel only proxies release (less storage in
|
||||||
|
SelfPost, tighter rspamd coupling).
|
||||||
|
2. **What "release" means**
|
||||||
|
- inject into Postfix as a new delivery to the domain's upstream transport;
|
||||||
|
- or manual download only (no automatic forward).
|
||||||
|
3. **Retention and caps**
|
||||||
|
- max age, max total bytes, per-domain limits; interaction with backup size.
|
||||||
|
4. **RBAC**
|
||||||
|
- global admin only vs domain-admin for assigned inbound domains.
|
||||||
|
5. **Threat model**
|
||||||
|
- malware in stored MIME, path traversal on extract, quota DoS on busy MX.
|
||||||
|
6. **Product fit**
|
||||||
|
- does this violate [product.md](../product.md) "no mailboxes" if we only
|
||||||
|
hold spam suspects briefly? Explicit product decision required.
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
- Inbound relay forwards or rejects; no local delivery
|
||||||
|
([architecture.md](../architecture.md) § Mail path).
|
||||||
|
- Optional `INBOUND_ANTISPAM_MILTER` — external rspamd may quarantine on its
|
||||||
|
own today, without panel integration.
|
||||||
|
- DMARC `p=quarantine` is unrelated (outbound policy for receivers).
|
||||||
|
|
||||||
|
## Likely out of scope (until revisited)
|
||||||
|
|
||||||
|
- IMAP/webmail for quarantined mail.
|
||||||
|
- End-user self-service quarantine (non-admin recipients).
|
||||||
|
- Outbound quarantine.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Inbound relay, shipped in `[1.4.0]` — as built in
|
||||||
|
[architecture.md](../architecture.md) § Mail path.
|
||||||
|
- Sensible to decide **after** or **alongside**
|
||||||
|
[inbound-antispam-panel.md](inbound-antispam-panel.md) (journal/lists), but
|
||||||
|
not blocked on it for design discussion.
|
||||||
|
|
||||||
|
## Done when (placeholder)
|
||||||
|
|
||||||
|
TBD once approach is chosen. Minimum bar would include: opt-in flag, panel
|
||||||
|
list/detail, release or discard action, retention, backup inclusion documented,
|
||||||
|
security review.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
None yet — expand this plan after the open questions above are answered.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Plan: panel-redesign
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
**Date:** 2026-09-08
|
||||||
|
**Version:** `1.x` MINOR once implementation is agreed; this file is givens, not a
|
||||||
|
build plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this file is
|
||||||
|
|
||||||
|
Source data for a visual redesign of the control panel. Mockups have not been
|
||||||
|
drawn yet. Implementation in `internal/web` waits on those mockups and on
|
||||||
|
roadmap status **agreed**.
|
||||||
|
|
||||||
|
HTML click-throughs in [docs/assets/panel-ui/](../assets/panel-ui/index.html)
|
||||||
|
are **not** the brief. They were an earlier layout experiment; new screens are
|
||||||
|
drawn from this file's vocabulary, not copied from that folder.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Stop hand-tuning every card on every page to hide meaningless empty space.
|
||||||
|
Operators should get a panel whose width rules are the same on Status, a
|
||||||
|
delete-confirm, and a six-column log: a form stays at a reading measure, ops
|
||||||
|
content uses the remaining column, and a card fills its cell.
|
||||||
|
|
||||||
|
This is a visual and CSS-grammar change. Routes, POST behaviour, and htmx
|
||||||
|
fragments stay as they are unless a later mockup proves a control must move.
|
||||||
|
|
||||||
|
## Pain (why)
|
||||||
|
|
||||||
|
[panel.css](../../internal/web/view/static/panel.css) already juggles several
|
||||||
|
widths (`64rem` column, `48rem` measure, `main.wide`, `.card.narrow`,
|
||||||
|
`page-login`) and two names for one pairing (`.split` everywhere, `.pair` on
|
||||||
|
DMARC). Each new screen picks a width by eye. On a wide monitor that leaves
|
||||||
|
voids; stretching a single input to the window is unreadable. The fix is a
|
||||||
|
layout grammar, not another button library.
|
||||||
|
|
||||||
|
## Delivery constraints (fixed unless product says otherwise)
|
||||||
|
|
||||||
|
The panel is not an SPA. Changing any of these needs an explicit decision, not
|
||||||
|
a mockup flourish:
|
||||||
|
|
||||||
|
| Constraint | Where |
|
||||||
|
|---|---|
|
||||||
|
| Go `html/template`, server-rendered pages | [internal/web/view/](../../internal/web/view/) |
|
||||||
|
| Vendored htmx 2.x for polling fragments | [layout.html](../../internal/web/view/templates/layout.html), [development.md](../development.md) § Vendored front-end |
|
||||||
|
| Assets embedded (`//go:embed`), no CDN at runtime | [view.go](../../internal/web/view/view.go) |
|
||||||
|
| CSP `default-src 'self'` — no inline script or `style=""` | [security.go](../../internal/web/security.go), `TestNoTemplateUsesInlineScriptOrStyle` |
|
||||||
|
| State changes are native HTML POST | templates under `internal/web/view/templates/` |
|
||||||
|
| AGPL-3.0; new front-end must be MIT-or-better and listed in NOTICE | [NOTICE](../../NOTICE), [development.md](../development.md) |
|
||||||
|
|
||||||
|
JavaScript is not a product requirement. [security.md](../security.md) records
|
||||||
|
progressive enhancement so **authorization does not depend on JS** (origin
|
||||||
|
check, session, RBAC). `data-confirm` is deliberately not a security boundary.
|
||||||
|
The redesign may use JS for chrome (copy, `<dialog>`). It must not make a
|
||||||
|
script the only way to submit a form.
|
||||||
|
|
||||||
|
Brand carriers today: stamp, brick, IBM Plex — [product.md](../product.md) and
|
||||||
|
[docs/assets/selfpost-proof.html](../assets/selfpost-proof.html). New mockups
|
||||||
|
may restyle; they must not silently drop the mark or invent a second product
|
||||||
|
look.
|
||||||
|
|
||||||
|
## Screens in scope (information architecture)
|
||||||
|
|
||||||
|
Same pages as the live panel. Visibility flags (inbound, DMARC, global vs
|
||||||
|
domain-admin) stay as in [web.go](../../internal/web/web.go).
|
||||||
|
|
||||||
|
| Kind | Pages |
|
||||||
|
|---|---|
|
||||||
|
| Signed-out | login, setup |
|
||||||
|
| Global ops | Status (plus polled fragment), mail queue, system log, backup, users |
|
||||||
|
| Shared | domains, domain detail, deliveries, delivery, help, settings |
|
||||||
|
| Optional | inbound (+ domain, delete), DMARC hub / domain / report |
|
||||||
|
| Confirms / forms | domain delete, inbound delete, user create/edit/delete |
|
||||||
|
|
||||||
|
## Layout layer (decided)
|
||||||
|
|
||||||
|
**Hybrid.** Page-level regions are ours. In-page composition is vendored Web
|
||||||
|
Awesome **layout CSS only** (MIT Core), not the component library and not the
|
||||||
|
full `utilities.css` barrel.
|
||||||
|
|
||||||
|
Every Layout ([every-layout.dev](https://every-layout.dev/)) is the *school*
|
||||||
|
(Stack, Cluster, Sidebar, Grid). It is a paid book; redistribution is
|
||||||
|
forbidden. Do not vendor it, do not buy it for the image, do not put their
|
||||||
|
custom elements in the tree. Web Awesome `layout.css` is the MIT packaging of
|
||||||
|
those recipes (`wa-stack` ≈ Stack, `wa-cluster` ≈ Cluster, `wa-flank` ≈
|
||||||
|
Sidebar, `wa-grid` ≈ Grid/Switcher).
|
||||||
|
|
||||||
|
### Regions (our CSS)
|
||||||
|
|
||||||
|
Shell: navigation column plus content that takes the rest (today's `.shell`,
|
||||||
|
without a centred `64rem` well that leaves side voids).
|
||||||
|
|
||||||
|
Inside the content column, **two modes only**. A page must not invent a third
|
||||||
|
width:
|
||||||
|
|
||||||
|
- **`measure`** — one readable form (login, setup, delete confirm, user form).
|
||||||
|
Cap around `42rem`, **left-aligned**. Fields fill the measure, not the
|
||||||
|
window. Empty space to the right of a form on a wide monitor is acceptable.
|
||||||
|
- **`fill`** — ops: Status, tables, logs, queue, DNS, paired cards. One column
|
||||||
|
cap for all fill pages (pick a single value in mockups, e.g. ~`90rem`; do
|
||||||
|
not keep both `48rem` and `64rem`). A card fills its grid cell.
|
||||||
|
|
||||||
|
There is no `main.wide` or `.card.narrow` as a width exception. A narrow card
|
||||||
|
lives in `measure`. A wide table lives in `fill`.
|
||||||
|
|
||||||
|
### In-page (vendored WA CSS)
|
||||||
|
|
||||||
|
Embed under `/static`, same as htmx — never a runtime CDN.
|
||||||
|
|
||||||
|
| Take | Do not take |
|
||||||
|
|---|---|
|
||||||
|
| `utilities/layout.css` | barrel `utilities.css` |
|
||||||
|
| `utilities/gap.css` | `themes/default.css`, `native.css` |
|
||||||
|
| `align-items.css`, `justify-content.css`, `flex-wrap.css` if a mockup needs them | variants, prose, FOUCE, JS, icons, Pro |
|
||||||
|
| A short `:root` stub for `--wa-space-*` only | their `layers.css` (it styles `wa-page`) |
|
||||||
|
|
||||||
|
Declare `@layer wa-utilities` locally. One spacing scale for the whole layer
|
||||||
|
(map to today's `1.2rem` gaps if that looks right; do not keep a second scale).
|
||||||
|
|
||||||
|
`.card` with slots (head / body / actions) stays ours so a `wa-grid` cell can
|
||||||
|
stretch. Do not use `wa-card`.
|
||||||
|
|
||||||
|
### Class vocabulary
|
||||||
|
|
||||||
|
**Paired cards are `wa-grid`, not `wa-split`.** In Web Awesome, `wa-split` is
|
||||||
|
space-between (title ‖ action). `wa-grid` is `auto-fit` + `--min-column-size`,
|
||||||
|
which is today's `.split { minmax(22rem, 1fr) }`. Freeze `--min-column-size`
|
||||||
|
once (same order as `22rem`); do not retune it per page.
|
||||||
|
|
||||||
|
| Class | Use |
|
||||||
|
|---|---|
|
||||||
|
| `measure` / `fill` | page region only, never inside a card |
|
||||||
|
| `wa-stack` | form or card body; children stretch on the block axis |
|
||||||
|
| `wa-cluster` | button rows, tags, Copy beside a value |
|
||||||
|
| `wa-grid` | two or more equal cards or field columns |
|
||||||
|
| `wa-split` | card header: title ‖ help/action; label ‖ value |
|
||||||
|
| `wa-flank` | rare: fixed + fluid (icon beside a field) |
|
||||||
|
| `wa-gap-*` | change a gap; not a substitute for a region |
|
||||||
|
|
||||||
|
`wa-frame` is for media boxes; the panel has none — omit from mockups.
|
||||||
|
|
||||||
|
One child in `wa-grid` occupies the row (`auto-fit`, not `auto-fill`): a
|
||||||
|
domain-admin Settings card must not leave an empty second column.
|
||||||
|
|
||||||
|
**Forbidden in mockups and later in templates:** a one-off `max-width` “for
|
||||||
|
this page”; a second name for the same pairing (today `.pair` vs `.split`);
|
||||||
|
`margin-inline: auto` on a card inside a grid (that already collapsed `.split`
|
||||||
|
tracks).
|
||||||
|
|
||||||
|
**Ready when:** Status (`fill` + `wa-grid` pairs) and login (`measure` +
|
||||||
|
`wa-stack`) can be drawn with no extra width rule. If a third screen wants a
|
||||||
|
new `max-width`, the vocabulary is unfinished.
|
||||||
|
|
||||||
|
## Components (decided)
|
||||||
|
|
||||||
|
**No JavaScript component library.** Pico, Bootstrap JS, DaisyUI, FAST,
|
||||||
|
Shoelace, and Web Awesome custom elements (`wa-button`, `wa-dialog`,
|
||||||
|
`wa-drawer`, `wa-card`) are out. They do not fix empty cards; they add Lit or
|
||||||
|
another runtime, Shadow DOM, CSP risk, and friction with htmx swaps.
|
||||||
|
|
||||||
|
What the live panel actually uses — and what mockups should assume:
|
||||||
|
|
||||||
|
| Need | Source |
|
||||||
|
|---|---|
|
||||||
|
| Button, input, select, textarea, table | native HTML, our CSS (brick / Plex) |
|
||||||
|
| Card | our `.card` + slots |
|
||||||
|
| Flash, status tint | our CSS |
|
||||||
|
| Help / application panels | CSS disclosure (checkbox today); or `<details>` / Popover if a mockup prefers |
|
||||||
|
| Destructive confirm | `data-confirm` / `window.confirm`, plus existing confirm pages for high blast radius |
|
||||||
|
| Overlay, if a mockup truly needs one | HTML [`<dialog>`](https://developer.mozilla.org/docs/Web/HTML/Element/dialog), not `wa-dialog` |
|
||||||
|
| Copy-to-clipboard | existing `panel.js` |
|
||||||
|
|
||||||
|
There is no combobox, date picker, tab set, tree, or chart in the panel. If a
|
||||||
|
future mockup invents a widget native HTML cannot express, choose a library
|
||||||
|
then, for that widget only.
|
||||||
|
|
||||||
|
## Out of scope for this item
|
||||||
|
|
||||||
|
- New panel features, routes, or copy (those are other roadmap rows).
|
||||||
|
- Weakening CSP to accommodate a widget library.
|
||||||
|
- Web Awesome Pro, Font Awesome Kit, a JS bundler for Lit.
|
||||||
|
- Replacing htmx with a front-end framework.
|
||||||
|
- Implementing `docs/assets/panel-ui/` as-is.
|
||||||
|
|
||||||
|
## Next (not this file's job)
|
||||||
|
|
||||||
|
1. Draw new mockups using only the vocabulary above.
|
||||||
|
2. After mockups are accepted and this item is **agreed**: vendor the WA CSS
|
||||||
|
files, NOTICE / development.md rows, then restyle templates without changing
|
||||||
|
behaviour.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
None until mockups exist and the roadmap status is **agreed**. Do not start
|
||||||
|
vendoring or rewriting `internal/web` from this file alone.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Plan: preflight (installation check page)
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A dedicated `/preflight` page (global-admin only) that runs infrastructure-level
|
||||||
|
checks and helps the administrator confirm the instance is fully configured and
|
||||||
|
ready to deliver mail. Each failed check shows an actionable recommendation
|
||||||
|
(what to do or where to look).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- rDNS / PTR verification (reuse `dnscheck.Checker.checkServer`)
|
||||||
|
- TLS certificate validity + hostname match (CN/SAN contains `SELFPOST_HOSTNAME`)
|
||||||
|
- Port connectivity: 25, 465, and 587 (if `SUBMISSION_ENABLE`) — dial own
|
||||||
|
public IP from inside the container, expect a 220 banner
|
||||||
|
- HELO/EHLO banner — connect to localhost:25, verify Postfix announces
|
||||||
|
`SELFPOST_HOSTNAME` (not `localhost` or a container ID)
|
||||||
|
- Trusted proxy headers:
|
||||||
|
- If `TRUSTED_PROXY_CIDR` set: verify peer IP is in range and
|
||||||
|
`X-Forwarded-For` is present
|
||||||
|
- If `PANEL_COOKIE_SECURE=true`: verify `X-Forwarded-Proto: https`
|
||||||
|
- If `TRUSTED_PROXY_CIDR` empty: informational warning (panel exposed
|
||||||
|
directly)
|
||||||
|
- DKIM signing — OpenDKIM milter socket reachable (reuse `health.CheckSocket`)
|
||||||
|
- Send test email — admin enters recipient address, system sends via
|
||||||
|
localhost:25, shows queued/error result
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Per-domain DNS checks (SPF, DKIM TXT, DMARC) — those live on domain detail
|
||||||
|
pages.
|
||||||
|
- Delivery tracking of test email (only confirms local MTA accepted).
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
An administrator can open `/preflight`, see a traffic-light summary of all
|
||||||
|
infrastructure prerequisites, read specific fix instructions for any failures,
|
||||||
|
and send a test email to verify end-to-end flow.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Port-connectivity check from inside the container may not reflect external
|
||||||
|
reachability if NAT hairpin is unsupported (document limitation).
|
||||||
|
- Test email requires at least one configured domain (or falls back to
|
||||||
|
`postmaster@SELFPOST_HOSTNAME`).
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: TBD (MINOR). One commit per step; code only after roadmap
|
||||||
|
status is **agreed**. See [development.md](../development.md) § Plan checklists.
|
||||||
|
|
||||||
|
- [ ] Port-connectivity helper (`internal/health/port.go`) — dial own IP, read
|
||||||
|
SMTP banner — **Sonnet**
|
||||||
|
- [ ] HELO banner check (connect localhost:25, parse 220 line) — **Sonnet**
|
||||||
|
- [ ] Hostname-in-certificate check (extend or wrap `health.CheckCertificate`)
|
||||||
|
— **Sonnet**
|
||||||
|
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
|
||||||
|
recommendations) — **Sonnet**
|
||||||
|
- [ ] Test-email handler (`POST /preflight/test-email`, `net/smtp` to
|
||||||
|
localhost:25) — **Sonnet**
|
||||||
|
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
|
||||||
|
**Sonnet**
|
||||||
|
- [ ] Routes in `web.go`, nav link — **Sonnet**
|
||||||
+31
-10
@@ -10,14 +10,25 @@ in [guide.md](guide.md); as-built technical detail in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
SelfPost is a self-hosted **outbound SMTP relay** with a web control panel,
|
SelfPost is a self-hosted **SMTP relay** with a web control panel, shipped as a
|
||||||
shipped as a single Docker image. It sends mail directly to the internet from
|
single Docker image. It sends mail directly from your server with per-domain
|
||||||
your own IP with per-domain DKIM signing.
|
DKIM signing; an optional inbound relay forwards mail on port 25 without
|
||||||
|
storing it.
|
||||||
|
|
||||||
**Primary workflow:** configure the relay once (domains, DKIM, SASL
|
**Primary workflow:** configure the relay once (domains, DKIM, SASL
|
||||||
applications), then point scripts and apps at the SMTP endpoint. SelfPost
|
applications), then point scripts and apps at the SMTP endpoint. SelfPost
|
||||||
delivers as the configured sending domain.
|
delivers as the configured sending domain.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
| | Text |
|
||||||
|
|---|---|
|
||||||
|
| Stamp subtitle | SELF-HOSTED SMTP RELAY |
|
||||||
|
| Tagline | YOUR SMTP. YOUR RULES. |
|
||||||
|
| Hero line (README, GitHub) | Self-hosted SMTP relay with a web panel — one Docker image, no provider. |
|
||||||
|
|
||||||
|
Brand assets and metrics: [docs/assets/selfpost-proof.html](assets/selfpost-proof.html).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment context (fixed assumptions)
|
## Deployment context (fixed assumptions)
|
||||||
@@ -47,14 +58,23 @@ Explicitly excluded to prevent scope creep:
|
|||||||
|
|
||||||
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
||||||
- Webmail
|
- Webmail
|
||||||
- Multi-user panel / organisations / roles — one administrator; managing
|
- Organisations / tenancy beyond global + domain-admin roles; managing
|
||||||
**multiple sending domains** is in scope (see below)
|
**multiple sending domains** under one global administrator is in scope (see
|
||||||
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
below)
|
||||||
|
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
|
||||||
|
expose a milter hook; it does not ship or start a filter
|
||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
|
The **domain-admin** role ships in the current line (global administrator plus
|
||||||
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
|
domain administrators with assigned domains). The optional **inbound relay**
|
||||||
|
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
|
||||||
|
`INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
|
||||||
|
retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
|
||||||
|
**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
|
||||||
|
ingest** ships in `[1.7.0]`, off by default behind `DMARC_REPORTS_ENABLE`.
|
||||||
|
Items marked *candidate* in the
|
||||||
|
[roadmap](roadmap.md) require explicit approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,5 +119,6 @@ Adding a domain does **not** create an application automatically.
|
|||||||
- **Delete domain** — removes DKIM key and **all** its applications.
|
- **Delete domain** — removes DKIM key and **all** its applications.
|
||||||
- **Delete application** — removes only that app's SASL and map entries.
|
- **Delete application** — removes only that app's SASL and map entries.
|
||||||
|
|
||||||
This is not multi-tenancy (one admin); it is one owner operating several
|
This is not multi-tenancy; it is one owner (or a small team with global and
|
||||||
sending domains with independent application credentials.
|
domain-scoped roles) operating several sending domains with independent
|
||||||
|
application credentials.
|
||||||
+322
-106
@@ -1,133 +1,349 @@
|
|||||||
# Дорожная карта: SelfPost 2.x.x
|
# Roadmap: open work (1.x+)
|
||||||
|
|
||||||
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
|
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
|
||||||
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
|
only after explicit agreement ([product.md](product.md),
|
||||||
только после явного согласования ([product.md](product.md),
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
|
design lives in [plans/](plans/). Items marked `candidate` need an OK before
|
||||||
[product.md](product.md) явно исключает часть этого объёма (приём входящей
|
any code is written.
|
||||||
почты; несколько пользователей/роли), поэтому включение — сознательное
|
|
||||||
расширение границ проекта, а не доработка по своей инициативе. Присутствие
|
|
||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
|
||||||
решением.
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Reading this from outside the project:** nothing here is a commitment or a
|
||||||
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
release promise. There are no dates, the order is a recommendation rather than
|
||||||
|
a schedule, and an item can be dropped or reshaped once its plan is written.
|
||||||
|
What the project *will not* do is a separate question, answered in
|
||||||
|
[product.md](product.md) — an item's absence from this file does not mean it is
|
||||||
|
planned but unlisted.
|
||||||
|
|
||||||
|
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
||||||
|
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||||||
|
an explicit break. One such break, when 2.x is cut for any reason, is
|
||||||
|
[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain
|
||||||
|
with a baseline. That item does not by itself justify a major.
|
||||||
|
|
||||||
|
**Process:** [development.md](development.md). The history of closed phases is
|
||||||
|
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ID | Topic | Status | Progress | Plan |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||||
|
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||||
|
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
||||||
|
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
||||||
|
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||||
|
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
|
||||||
|
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
|
||||||
|
| panel-redesign | Panel visual redesign (layout grammar) | candidate | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
|
||||||
|
|
||||||
|
**Recommended order** (not binding): the next feature is
|
||||||
|
**inbound-antispam-panel** (`1.10.0`). panel-docs shipped in
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
|
||||||
|
(security review of the ingest path pending); domain-stats-auto-ratelimit in
|
||||||
|
`[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
|
||||||
|
queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
|
||||||
|
`[1.3.0]`. Candidates need explicit agreement before they join the queue.
|
||||||
|
|
||||||
|
The 2026-08-19 full-tree code review is closed except for four items, all
|
||||||
|
listed above and none of them urgent: [csrf-tokens](#csrf-tokens),
|
||||||
|
[template-data-typing](#template-data-typing),
|
||||||
|
[structured-logging](#structured-logging) and
|
||||||
|
[review-2026-08-followups](#review-2026-08-followups). What that review
|
||||||
|
produced is in [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`; its own document
|
||||||
|
was removed once these four items carried everything still open, and stays in
|
||||||
|
git history.
|
||||||
|
|
||||||
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
|
the **Implementation checklist** in its linked plan. The `Progress` column above
|
||||||
|
is `done/total` checklist steps in that plan ([development.md](development.md)
|
||||||
|
§ Plan checklists).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## contributing
|
||||||
|
|
||||||
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
|
before a PR, the commit protocol; [development.md](development.md) links to it
|
||||||
|
rather than repeating it.
|
||||||
|
|
||||||
|
**Boundary:** process documentation; worth writing once there is an external
|
||||||
|
flow of PRs.
|
||||||
|
|
||||||
|
**Done when:** the file is in the root and development.md does not duplicate
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||||||
|
priority.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## panel-docs
|
||||||
|
|
||||||
|
**Status:** done — shipped in `[1.8.0]` (2026-08-18). The implementation plan
|
||||||
|
file was removed as shipped cleanup; release details remain in
|
||||||
[CHANGELOG.md](../CHANGELOG.md).
|
[CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
**Goal:** built-in operator documentation in the panel — short pages (or a
|
||||||
|
help drawer) that explain what each Status check and other controls mean,
|
||||||
|
without sending the operator out to `docs/guide.md`.
|
||||||
|
|
||||||
|
**Boundary:** in-panel help only; not a second copy of the full operator guide.
|
||||||
|
Seed content includes the Status blurbs removed from the cards in favour of a
|
||||||
|
denser layout — Machine (kernel counters / rate window), TLS certificate
|
||||||
|
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
|
||||||
|
reverse DNS, PTR at the hosting provider), and similar notes for other panel
|
||||||
|
surfaces as they lose inline commentary.
|
||||||
|
|
||||||
|
**Done when:** an operator can open help from the panel for those topics; the
|
||||||
|
removed Status blurbs are preserved there (or equivalent); no requirement to
|
||||||
|
read the git tree for day-to-day meaning of a card.
|
||||||
|
|
||||||
|
**Dependencies / risks:** copy ownership and translation; keeping help in sync
|
||||||
|
when checks change; not bloating every page with a second column of prose.
|
||||||
|
|
||||||
|
**Version:** shipped `1.8.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## inbound-antispam-panel
|
||||||
|
|
||||||
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
**Goal:** panel view of inbound anti-spam decisions (when, from, to, subject,
|
||||||
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
action) plus editable allow/deny lists that tune the external filter — without
|
||||||
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
shipping rspamd or another engine inside the image.
|
||||||
|
|
||||||
| Тема | Итог |
|
**Boundary:** metadata journal and list sync only; SelfPost does not run the
|
||||||
|
filter ([product.md](product.md)). Quarantine:
|
||||||
|
[inbound-quarantine](#inbound-quarantine). Design closed in
|
||||||
|
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Decisions.
|
||||||
|
|
||||||
|
**Done when:** with inbound relay enabled, the operator sees journal rows
|
||||||
|
(accepts via inbound-journal milter, rejects via mail.log tailer) and can
|
||||||
|
maintain instance-wide allow/deny lists synced to rspamd maps when the antispam
|
||||||
|
hook is set.
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
|
||||||
|
journal PII; global-admin-only RBAC.
|
||||||
|
|
||||||
|
**Version:** `1.10.0` MINOR; **agreed**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## inbound-quarantine
|
||||||
|
|
||||||
|
**Goal:** optional hold for suspicious inbound mail — review in the panel, then
|
||||||
|
release to upstream, discard, or tune lists. Design not settled.
|
||||||
|
|
||||||
|
**Boundary:** expands inbound from pure relay toward short-lived message
|
||||||
|
storage; may conflict with "no mailboxes" in [product.md](product.md) until
|
||||||
|
product explicitly agrees. Alternatives (rspamd-only quarantine vs SelfPost
|
||||||
|
store) are listed in [plans/inbound-quarantine.md](plans/inbound-quarantine.md).
|
||||||
|
|
||||||
|
**Done when:** TBD after open questions in the plan are answered.
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound relay `[1.4.0]`; storage/retention/backup
|
||||||
|
size; malware in held MIME; overlap with
|
||||||
|
[inbound-antispam-panel](#inbound-antispam-panel). No implementation checklist
|
||||||
|
yet.
|
||||||
|
|
||||||
|
**Version:** TBD; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## preflight
|
||||||
|
|
||||||
|
**Goal:** a `/preflight` page (global-admin only) that verifies instance-level
|
||||||
|
infrastructure: rDNS/PTR, TLS certificate + hostname match, port 25/465/587
|
||||||
|
reachability, HELO banner correctness, reverse-proxy headers, DKIM milter
|
||||||
|
socket, and a test-email sender. Each check shows an actionable fix
|
||||||
|
recommendation on failure.
|
||||||
|
|
||||||
|
**Boundary:** instance infrastructure only; per-domain DNS checks remain on
|
||||||
|
domain detail pages. Does not track test-email delivery beyond local MTA
|
||||||
|
acceptance.
|
||||||
|
|
||||||
|
**Done when:** the page loads, all checks run with traffic-light results and
|
||||||
|
recommendations, and the test-email form successfully submits a message through
|
||||||
|
Postfix.
|
||||||
|
|
||||||
|
**Dependencies / risks:** port-connectivity check may not reflect external
|
||||||
|
reachability behind NAT without hairpin; test email needs at least one domain or
|
||||||
|
falls back to `postmaster@SELFPOST_HOSTNAME`.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR; `candidate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## csrf-tokens
|
||||||
|
|
||||||
|
**Goal:** a per-session CSRF token on every state-changing form, checked
|
||||||
|
server-side in addition to the `Sec-Fetch-Site` / `Origin` check the panel
|
||||||
|
relies on today (`originAllowed` in
|
||||||
|
[internal/web/security.go](../internal/web/security.go)).
|
||||||
|
|
||||||
|
**Boundary:** defence in depth, not a replacement — the header check stays and
|
||||||
|
keeps covering requests that carry no form. The token is bound to the existing
|
||||||
|
session record; no new session model, no cookie changes.
|
||||||
|
|
||||||
|
**Done when:** every POST form and htmx fragment that changes state carries a
|
||||||
|
token, a missing or mismatched token is refused with a test that proves it, and
|
||||||
|
[security.md](security.md) describes the layered model in one place instead of
|
||||||
|
two.
|
||||||
|
|
||||||
|
**Dependencies / risks:** [security.md](security.md) currently records the
|
||||||
|
opposite decision — origin checking without tokens, with the accepted risk and
|
||||||
|
its revisit triggers written out in the ADR § *CSRF via origin checking*. The
|
||||||
|
2026-08-19 review recorded "add tokens" without turning it into a task, which is
|
||||||
|
why this item exists. Settle which decision stands before writing code; both
|
||||||
|
outcomes are honest, and "keep headers" means deleting this item rather than
|
||||||
|
leaving the contradiction standing. The work itself is a hidden field in roughly
|
||||||
|
two dozen forms plus the htmx swap paths.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR; `candidate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## template-data-typing
|
||||||
|
|
||||||
|
**Goal:** replace the `map[string]any` template payloads in
|
||||||
|
`internal/web/handlers` with typed structs (`DomainDetailData`, `SettingsData`,
|
||||||
|
...), so a renamed or mistyped key is a compile error instead of a silently
|
||||||
|
empty page section.
|
||||||
|
|
||||||
|
**Boundary:** handler-to-template plumbing only; no template redesign, no route
|
||||||
|
or behaviour change. `renderDomainDetail`
|
||||||
|
([handlers_apps.go](../internal/web/handlers/handlers_apps.go)) sets around
|
||||||
|
forty keys on one map and is the case that motivates the item; roughly ten
|
||||||
|
render sites still pass maps.
|
||||||
|
|
||||||
|
**Done when:** the page handlers pass structs, the shared footer/base fields
|
||||||
|
(`Version`, `Copyright`, `SourceURL`) still arrive on every page, and the
|
||||||
|
template guard tests pass unchanged.
|
||||||
|
|
||||||
|
**Dependencies / risks:** mechanical but wide — it touches every
|
||||||
|
`handlers_*.go` and every template that reads those keys, and a missed key is
|
||||||
|
invisible unless a test renders that page. Worth pairing with the handler tests
|
||||||
|
in [review-2026-08-followups](#review-2026-08-followups) so the pages are
|
||||||
|
covered before they are rewired. Reviewer's estimate: 2–3 hours.
|
||||||
|
|
||||||
|
**Version:** no bearing on semver; `candidate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## structured-logging
|
||||||
|
|
||||||
|
**Goal:** move from `log.Printf` to `log/slog` — levels, structured fields, and
|
||||||
|
one logger injected through constructors, instead of the package-level
|
||||||
|
`logf = log.Printf` alias repeated in five packages.
|
||||||
|
|
||||||
|
**Boundary:** internal logging only. The destination stays stdout/stderr under
|
||||||
|
supervisord; no log shipping, no new dependency. Lines the panel's own Status
|
||||||
|
page and log tailer depend on must keep parsing.
|
||||||
|
|
||||||
|
**Done when:** packages take a `*slog.Logger`, the per-package `logf` aliases
|
||||||
|
are gone, and levels are used deliberately (an error is not an info).
|
||||||
|
|
||||||
|
**Dependencies / risks:** the log tailer reads Postfix's log rather than the
|
||||||
|
panel's, so the blast radius is small — but anything that greps the panel's
|
||||||
|
output (e2e assertions, operator habits) sees a new format. Reviewer's
|
||||||
|
estimate: 3–4 hours.
|
||||||
|
|
||||||
|
**Version:** no bearing on semver; `candidate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## review-2026-08-followups
|
||||||
|
|
||||||
|
**Goal:** the leftovers of the 2026-08-19 full-tree code review that are too
|
||||||
|
small to be roadmap items of their own. Everything still open from that review
|
||||||
|
is in the table below; the rest of it shipped in
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`.
|
||||||
|
|
||||||
|
| Area | Item |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
| Security | Re-authenticate (ask for the current password) before a backup download — [handlers_backup.go](../internal/web/handlers/handlers_backup.go) hands the archive to any signed-in session |
|
||||||
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
| Tests | The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests |
|
||||||
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
| Tests | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly |
|
||||||
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
| Tests | Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats |
|
||||||
|
| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job |
|
||||||
|
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
|
||||||
|
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
|
||||||
|
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
|
||||||
|
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout |
|
||||||
|
| UI | Two `back_link` elements in a row in `dmarc_domain.html` |
|
||||||
|
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
|
||||||
|
|
||||||
Открытая работа дальше — только секции 2.x ниже.
|
**Boundary:** no feature work and no behaviour change an operator would notice,
|
||||||
|
apart from the backup re-auth prompt. Anything here that grows past an hour
|
||||||
|
becomes its own roadmap item instead.
|
||||||
|
|
||||||
|
**Done when:** each row is either implemented or struck with a written reason.
|
||||||
|
|
||||||
|
**Dependencies / risks:** the test rows overlap
|
||||||
|
[template-data-typing](#template-data-typing) — write the handler tests first if
|
||||||
|
both are taken up. One finding of that review was **declined** and is
|
||||||
|
deliberately absent: `//go:build linux` for the `/proc` reader in
|
||||||
|
[internal/health/machine.go](../internal/health/machine.go). That code is
|
||||||
|
parameterised by root directory, calls nothing platform-specific, already
|
||||||
|
degrades gracefully when `/proc` is missing (`TestMachineSamplerWithoutProc`),
|
||||||
|
and tagging it would only delete cross-platform test coverage.
|
||||||
|
|
||||||
|
**Version:** no bearing on semver; `candidate`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
|
## panel-redesign
|
||||||
|
|
||||||
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
|
**Goal:** a panel layout grammar so screens stop inventing per-page widths to
|
||||||
|
hide empty space. Forms stay on a reading measure; ops pages use the remaining
|
||||||
|
column; cards fill their cells. Givens (hybrid WA layout CSS + own `measure` /
|
||||||
|
`fill`, no JS component library, new mockups rather than
|
||||||
|
[docs/assets/panel-ui/](assets/panel-ui/index.html)):
|
||||||
|
[plans/panel-redesign.md](plans/panel-redesign.md).
|
||||||
|
|
||||||
**Зачем это нужно (сценарии):**
|
**Boundary:** visual CSS and templates after mockups. No new routes or mail-path
|
||||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
|
behaviour. Not a front-end framework, not Web Awesome components, not Pico /
|
||||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
|
Bootstrap. The older HTML mockups in `docs/assets/panel-ui/` are not the brief.
|
||||||
|
|
||||||
**Граница объёма (критично — что это НЕ):**
|
**Done when:** mockups exist that use only the frozen vocabulary, then — once
|
||||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
|
this item is agreed — the live panel matches them without one-off `max-width`
|
||||||
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
|
exceptions. The givens file itself is not “done”.
|
||||||
|
|
||||||
**Почему как опция/плагин:**
|
**Dependencies / risks:** competes with feature work for attention; easy to
|
||||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
start restyling `internal/web` before the vocabulary is proven on paper.
|
||||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
Vendoring WA CSS needs NOTICE / licence rows. CSP stays `default-src 'self'`.
|
||||||
|
|
||||||
**Что делать:**
|
**Version:** `1.x` MINOR when implementation is agreed; `candidate` until then.
|
||||||
- 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 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
|
|
||||||
|
|
||||||
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Роль администратора домена — кандидат на 2.x
|
## schema-squash
|
||||||
|
|
||||||
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
**Goal:** when 2.x is cut, stop shipping the 1.x migration chain in the binary
|
||||||
|
and replace it with one baseline equal to the schema at the then-current head.
|
||||||
|
Fresh 2.x data directories no longer create-then-drop the historical `admin`
|
||||||
|
table. Current chain, legacy notes, and gate thresholds:
|
||||||
|
[schema-migrations.md](schema-migrations.md) (update that file when migrations
|
||||||
|
ship; adjust the squash gate to its head at cut time).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
|
**Boundary:** 1.x keeps the full chain so a `1.0.0` data directory still boots.
|
||||||
панели, роли» к out of scope (один администратор), поэтому появление второго
|
Do not delete, rename, or reorder migration files while MINOR compatibility with
|
||||||
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
|
`1.0.0` holds. Git history keeps the old files either way; only the embedded
|
||||||
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
|
set in the 2.x image changes.
|
||||||
|
|
||||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
|
Restore remains a separate lock: the backup manifest version must match the
|
||||||
|
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
||||||
|
replace the squash upgrade gate.
|
||||||
|
|
||||||
---
|
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
||||||
|
it); the gate in [schema-migrations.md](schema-migrations.md) is tested; the
|
||||||
|
operator guide says a 2.x image will not open an unfinished 1.x database.
|
||||||
|
|
||||||
## `CONTRIBUTING.md` — кандидат на 2.x
|
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
||||||
|
explicit major). Squashing the current short chain is not a reason to cut 2.x on
|
||||||
|
its own. A missed gate leaves a mid-chain 1.x database silently stuck.
|
||||||
|
**Version:** `2.x` major only; not a 1.x item.
|
||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
|
||||||
моделей по типу работы, протокол коммитов, требование
|
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
|
||||||
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
|
||||||
работа, русский).
|
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
|
||||||
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
|
||||||
русском) и первый внешний интерес после публикации релиза.
|
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
|
||||||
дублирует его, а ссылается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
|
|
||||||
|
|
||||||
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
|
|
||||||
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
|
|
||||||
лежат хендлеры всех разделов панели, сессии,
|
|
||||||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
|
||||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
|
||||||
панели.
|
|
||||||
|
|
||||||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
|
||||||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
|
||||||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
|
||||||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
|
||||||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
|
||||||
2.x выше добавляют в него код — роль администратора домена приносит
|
|
||||||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
|
||||||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
|
||||||
|
|
||||||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
|
||||||
разрезан, либо зафиксировано, что он остаётся плоским.
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# SQLite schema and migrations
|
||||||
|
|
||||||
|
**What this file is.** A living reference for the embedded SQLite migration chain,
|
||||||
|
historical legacy that still runs on fresh 1.x installs, and the planned 2.x
|
||||||
|
schema squash. When you add or review a migration, update this file in the same
|
||||||
|
change.
|
||||||
|
|
||||||
|
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by
|
||||||
|
`internal/store/store.go` (`migrate()`).
|
||||||
|
|
||||||
|
**Related:** [architecture.md](architecture.md) § Persistence;
|
||||||
|
[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) §
|
||||||
|
Documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current snapshot
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Chain head | `user_version = 10` |
|
||||||
|
| Files | `0001_init.sql` … `0010_send_log_app_login_index.sql` (10 files) |
|
||||||
|
| Database file | `/data/selfpost.db` (bind mount) |
|
||||||
|
| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory |
|
||||||
|
|
||||||
|
Last updated in **[Unreleased]** (`0010_send_log_app_login_index`); **1.9.1**
|
||||||
|
had no schema change. The release column below records the cut a migration
|
||||||
|
shipped in — fill `0010`'s in at the next version cut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How migrations run
|
||||||
|
|
||||||
|
1. Migrations are embedded at build time (`//go:embed migrations/*.sql`).
|
||||||
|
2. Filenames are sorted lexicographically; **file order = version number**.
|
||||||
|
3. `PRAGMA user_version` records progress: after file *i* (0-based), version is
|
||||||
|
*i + 1*.
|
||||||
|
4. Each pending migration runs in its own transaction, then bumps `user_version`.
|
||||||
|
5. There is no down-migration; fixes ship as a new `00NN_*.sql` file.
|
||||||
|
|
||||||
|
```text
|
||||||
|
0001_init.sql → user_version 1
|
||||||
|
0002_sessions.sql → user_version 2
|
||||||
|
…
|
||||||
|
0009_application_auth_ips.sql → user_version 9
|
||||||
|
0010_send_log_app_login_index.sql → user_version 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implication:** never delete, rename, or reorder migration files while 1.x must
|
||||||
|
stay compatible with `1.0.0`. Git history keeps old files; only a 2.x cut may
|
||||||
|
replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration chain
|
||||||
|
|
||||||
|
| Ver | File | Shipped | Kind | Summary |
|
||||||
|
|-----|------|---------|------|---------|
|
||||||
|
| 1 | `0001_init.sql` | ≤ 1.0.0 | DDL | Core schema: `admin`, `settings`, `domains`, `applications`, `application_addresses`, `send_log`, `rate_limits` |
|
||||||
|
| 2 | `0002_sessions.sql` | ≤ 1.0.0 | DDL | `sessions` (token hash, sliding idle expiry) |
|
||||||
|
| 3 | `0003_logtail_state.sql` | 0.5.0 | DDL | `logtail_state` (log-tailer read offset + fingerprint) |
|
||||||
|
| 4 | `0004_dmarc_report_email.sql` | 1.1.0 | DDL | `admin.dmarc_report_email`, `domains.dmarc_rua` |
|
||||||
|
| 5 | `0005_panel_users.sql` | 1.2.0 | DDL + data | `users`, `user_domains`; migrate single `admin` row; copy profile `dmarc_report_email` to `settings`; **DROP `admin`** |
|
||||||
|
| 6 | `0006_inbound_relay.sql` | 1.4.0 | DDL | `inbound_domains`, `inbound_transports`, `inbound_recipients` |
|
||||||
|
| 7 | `0007_rate_limit_auto.sql` | 1.6.0 | DDL | `rate_limits.mode`, `auto_multiplier`, `auto_updated_at` |
|
||||||
|
| 8 | `0008_dmarc_reports.sql` | 1.7.0 | DDL | `dmarc_reports`, `dmarc_report_records` |
|
||||||
|
| 9 | `0009_application_auth_ips.sql` | 1.9.0 | DDL + data | `applications.auth_ip_restrict`, `auth_allowed_ips`; move legacy app `rate_limits.allowed_ips` into auth columns; clear those IPs on rate limits |
|
||||||
|
| 10 | `0010_send_log_app_login_index.sql` | Unreleased | DDL | `idx_send_log_app_login_created_at` — app-scoped rate-limit counts and per-application send statistics were scanning the `created_at` range |
|
||||||
|
|
||||||
|
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||||
|
for operators upgrading from older 1.x images.
|
||||||
|
|
||||||
|
The feature plans that introduced schema work (`inbound-relay`,
|
||||||
|
`domain-stats-auto-ratelimit`, `dmarc-reports`) were deleted once they
|
||||||
|
shipped; what each migration was for is the table above, and the release it
|
||||||
|
came with is in [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema after head (v10)
|
||||||
|
|
||||||
|
Tables present in a fully migrated database:
|
||||||
|
|
||||||
|
| Table | Introduced | Role |
|
||||||
|
|-------|------------|------|
|
||||||
|
| `settings` | 0001 | Key/value panel settings (retention, profile flags) |
|
||||||
|
| `domains` | 0001 (+ `dmarc_rua` in 0004) | Sending domains |
|
||||||
|
| `applications` | 0001 (+ auth IP cols in 0009) | SASL applications per domain |
|
||||||
|
| `application_addresses` | 0001 | Explicit From addresses (`list` mode) |
|
||||||
|
| `send_log` | 0001 | Delivery journal |
|
||||||
|
| `rate_limits` | 0001 (+ auto cols in 0007) | Level-2 limits per domain/application |
|
||||||
|
| `sessions` | 0002 | Panel login sessions |
|
||||||
|
| `logtail_state` | 0003 | Log-tailer persistence |
|
||||||
|
| `users` | 0005 | Panel users (`global`, `domain_admin`) |
|
||||||
|
| `user_domains` | 0005 | Domain-admin assignments |
|
||||||
|
| `inbound_domains` | 0006 | Inbound relay domains |
|
||||||
|
| `inbound_transports` | 0006 | Upstream host/port/TLS per inbound domain |
|
||||||
|
| `inbound_recipients` | 0006 | Allow-list when `recipient_mode = list` |
|
||||||
|
| `dmarc_reports` | 0008 | Parsed aggregate report summaries |
|
||||||
|
| `dmarc_report_records` | 0008 | Per-source rows inside a report |
|
||||||
|
|
||||||
|
**Not present after v10:** `admin` (dropped in 0005).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy and fresh-install artefacts
|
||||||
|
|
||||||
|
These are intentional in 1.x; they are the main motivation for
|
||||||
|
[schema-squash](roadmap.md#schema-squash) at 2.x.
|
||||||
|
|
||||||
|
### `admin` table (0001 → 0005)
|
||||||
|
|
||||||
|
On a **new** 1.x data directory the chain still:
|
||||||
|
|
||||||
|
1. Creates `admin` (0001),
|
||||||
|
2. Adds `admin.dmarc_report_email` (0004),
|
||||||
|
3. Copies the row into `users` and drops `admin` (0005).
|
||||||
|
|
||||||
|
Functionally harmless; confusing when reading migrations or inferring schema from
|
||||||
|
code. A 2.x baseline should define `users` directly and omit `admin`.
|
||||||
|
|
||||||
|
### Application trusted IPs (0009)
|
||||||
|
|
||||||
|
Before 1.9.0, client IPs for an application lived in `rate_limits.allowed_ips`
|
||||||
|
(`scope = application`). Migration 0009 copies non-empty values into
|
||||||
|
`applications.auth_allowed_ips`, sets `auth_ip_restrict = 1`, and clears
|
||||||
|
`rate_limits.allowed_ips` for application scope. Level-2 limits no longer carry
|
||||||
|
IP bindings; auth and rate limiting are separate concerns.
|
||||||
|
|
||||||
|
Note that the two columns do not mean the same thing: the old list was
|
||||||
|
*permissive* (those IPs got the application ceiling, every other IP fell back
|
||||||
|
to the domain limit), the new one is *restrictive* (every IP not on the list is
|
||||||
|
refused for that application). The conversion branch only fires for a database
|
||||||
|
that carried application-scope `allowed_ips` from before 1.9.0.
|
||||||
|
|
||||||
|
### Profile DMARC email (0004 → 0005)
|
||||||
|
|
||||||
|
`0004` adds `admin.dmarc_report_email`. `0005` copies it into the migrated global
|
||||||
|
`users` row and into `settings` key `dmarc_report_email`. Per-domain overrides
|
||||||
|
remain on `domains.dmarc_rua`.
|
||||||
|
|
||||||
|
### Documentation references to migration numbers
|
||||||
|
|
||||||
|
Other docs cite migrations by number (e.g. architecture § sessions → `0002`).
|
||||||
|
After a 2.x squash, those references remain valid for **upgrade history** and
|
||||||
|
git; fresh 2.x installs only run the baseline plus post-2.x files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules for 1.x changes
|
||||||
|
|
||||||
|
1. **Add** the next `00NN_descriptive_name.sql`; do not edit shipped migrations.
|
||||||
|
2. **Bump this file:** snapshot table, chain row, schema table if needed.
|
||||||
|
3. **CHANGELOG** under the release that ships the migration.
|
||||||
|
4. **Operator impact:** if upgrade behaviour matters, note it in [guide.md](guide.md).
|
||||||
|
5. **Backup manifest** is separate: restore requires matching binary version
|
||||||
|
([architecture.md](architecture.md) § Persistence); it does not replace
|
||||||
|
running pending SQLite migrations.
|
||||||
|
|
||||||
|
### Checklist for a new migration
|
||||||
|
|
||||||
|
- [ ] File name is next integer, zero-padded four digits.
|
||||||
|
- [ ] SQL is idempotent in spirit (runs once per DB; guard with schema state, not
|
||||||
|
“IF NOT EXISTS” everywhere unless needed).
|
||||||
|
- [ ] Data migrations handle empty/partial state (e.g. no `admin` row on re-run is
|
||||||
|
impossible; mid-upgrade failure is recovered by re-running the same file
|
||||||
|
only if the transaction failed before `user_version` bump).
|
||||||
|
- [ ] Row added to [Migration chain](#migration-chain) and [Current snapshot](#current-snapshot).
|
||||||
|
- [ ] `go test ./...` (store and dependents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned 2.x squash
|
||||||
|
|
||||||
|
Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x
|
||||||
|
on its own — only bundled with another breaking change or an explicit major.
|
||||||
|
|
||||||
|
**Goal:** embed one baseline SQL file equal to the schema at whatever the 1.x
|
||||||
|
chain head is when the squash ships (`v10` today), instead of the full 1.x
|
||||||
|
chain. Fresh 2.x `/data` directories skip create-then-drop `admin`.
|
||||||
|
|
||||||
|
**Upgrade gate (required when squash ships):**
|
||||||
|
|
||||||
|
| `user_version` | 2.x behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `0` (empty DB) | Apply baseline; set `user_version` to new chain head |
|
||||||
|
| `>= N` (fully migrated 1.x) | Skip; schema already matches baseline |
|
||||||
|
| `1`…`N-1` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x |
|
||||||
|
|
||||||
|
`N` is the chain head at cut time (`10` today).
|
||||||
|
|
||||||
|
**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that
|
||||||
|
2.x will not open an unfinished 1.x database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore vs migrate
|
||||||
|
|
||||||
|
| Mechanism | What it checks |
|
||||||
|
|-----------|----------------|
|
||||||
|
| SQLite `user_version` | Which embedded migrations have run on this `selfpost.db` |
|
||||||
|
| Backup `manifest.json` | Binary/image version after full restore |
|
||||||
|
|
||||||
|
An operator can have a matching manifest after restore but still need migrations
|
||||||
|
if they restored an older DB snapshot with a newer binary — normal `migrate()`
|
||||||
|
applies pending files. The 2.x gate adds a **refusal** for half-upgraded 1.x DBs
|
||||||
|
when the old chain is no longer embedded.
|
||||||
+242
-141
@@ -1,177 +1,278 @@
|
|||||||
# Безопасность
|
# Security
|
||||||
|
|
||||||
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
|
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
|
||||||
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
|
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
||||||
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
defence-in-depth — `--` перед логином в argv `saslpasswd2`
|
login in the `saslpasswd2` argv
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** —
|
([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review
|
||||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the
|
||||||
|
2026-08-13 full-tree review against this document — send-log authorization for
|
||||||
|
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
|
||||||
|
session creation, and application-delete ordering: no findings, and nothing
|
||||||
|
needed adding to the accepted risks. The 2026-08-19 review (Fable) covered the
|
||||||
|
whole diff from 1.3.0 (`7052395`) to HEAD past 1.9.1 — the inbound-relay path
|
||||||
|
(port 25, maps, panel), DMARC aggregate-report ingestion, automatic rate
|
||||||
|
limiting, application auth-IP allow-lists, and the new panel surface
|
||||||
|
(handlers, templates, settings): no exploitable findings; two deliberate
|
||||||
|
behaviours the pass examined are now recorded under Accepted risks
|
||||||
|
(unauthenticated DMARC ingest, fail-open auth-IP enforcement).
|
||||||
|
(2) **Accepted risks** —
|
||||||
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
|
lost.
|
||||||
|
|
||||||
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
|
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
|
||||||
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
|
cookie with duplicate detection — Phase 14) is done; the history is in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
|
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
|
||||||
|
|
||||||
Продуктовые границы: [product.md](product.md). Устройство as-built:
|
Product boundaries: [product.md](product.md). As-built design:
|
||||||
[architecture.md](architecture.md).
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обязательные требования
|
## Mandatory requirements
|
||||||
|
|
||||||
Панель публична из интернета — пункты ниже **не опциональны**.
|
The panel is exposed to the internet — the items below are **not optional**.
|
||||||
|
|
||||||
### Первичная инициализация администратора
|
### First-run administrator setup
|
||||||
|
|
||||||
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
|
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
|
||||||
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
|
ready-made password hash.
|
||||||
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
|
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
|
||||||
настройки — перегенерация и новый вывод в лог.
|
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
|
||||||
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
|
unfinished, it is regenerated and logged again.
|
||||||
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
|
- Rate limiting on `/setup/<token>` per IP, separate from login.
|
||||||
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
|
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
|
||||||
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
|
being DoS-ed).
|
||||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
|
- The administrator password is bcrypt in SQLite only; no plaintext and no
|
||||||
|
MD5.
|
||||||
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
### SASL-пароли приложений
|
### Application SASL passwords
|
||||||
|
|
||||||
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
|
- The panel **generates** the password on creation or reissue and shows it
|
||||||
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
|
**once**.
|
||||||
перевыпуск.
|
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||||
|
panel); a lost password can only be reissued.
|
||||||
|
|
||||||
### Ввод и конфигурация
|
### Input and configuration
|
||||||
|
|
||||||
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
|
- Server-side validation of addresses and domains (character whitelist);
|
||||||
защитой.
|
client-side validation does not count as protection.
|
||||||
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
|
- In address-list mode every address is checked to belong to the application's
|
||||||
- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского
|
domain before it is written.
|
||||||
ввода; аргументы отдельными элементами.
|
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||||
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
|
input; arguments are passed as separate elements.
|
||||||
|
- Writes to config files are escaped (no injection of Postfix directives).
|
||||||
|
|
||||||
### Аутентификация и сессии
|
### Authentication and sessions
|
||||||
|
|
||||||
- Rate limiting на логин (по IP, с блокировкой/задержкой).
|
- Rate limiting on login (per IP, with lockout or delay).
|
||||||
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
|
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||||
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
|
`SameSite`.
|
||||||
(`PANEL_SESSION_IDLE_DAYS`).
|
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||||
|
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||||
|
|
||||||
### Вывод и процесс
|
### Output and process
|
||||||
|
|
||||||
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
|
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||||
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
|
journal, themes).
|
||||||
группу `selfpost` и минимальные права.
|
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||||
|
is granted through the `selfpost` group with minimal permissions.
|
||||||
|
|
||||||
### Почтовый тракт (связанное с безопасностью)
|
### Mail path (security-relevant)
|
||||||
|
|
||||||
- **Не open relay** — только SASL; `reject_unauth_destination`;
|
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
|
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
|
||||||
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
|
open relay either: SASL is off; `smtpd_relay_restrictions` /
|
||||||
при rate-limit логина; пусто = XFF игнорируется.
|
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
|
||||||
|
`reject_unlisted_recipient`; maps list only configured domains and
|
||||||
|
recipients. Domains with no upstream host are omitted from the maps so mail
|
||||||
|
is never accepted with nowhere to send it. Prefer recipient mode `list` to
|
||||||
|
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
|
||||||
|
on inbound. An optional antispam milter is inbound-only; default action is
|
||||||
|
fail-open (`accept`).
|
||||||
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
|
`encrypt`).
|
||||||
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
|
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
|
||||||
|
|
||||||
### Резервная копия и экспорт домена
|
### Backup and domain export
|
||||||
|
|
||||||
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
|
||||||
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
|
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
|
||||||
текстом (иначе перенос без пересоздания кредов невозможен).
|
private key from `certs/` when present; a domain export carries the DKIM key
|
||||||
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
|
and **working** application passwords in the clear (otherwise a transfer
|
||||||
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
|
without recreating credentials would be impossible).
|
||||||
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
подменённый файл не открывается вместо тихого восстановления «хвоста».
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
|
a truncated or substituted file fails to open instead of silently restoring a
|
||||||
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
|
partial "tail". Format and wrapper:
|
||||||
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
|
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||||
шифрование по magic файла, а не по расширению.
|
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||||
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
|
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||||
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
|
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
|
||||||
аргументом (список процессов читается любым процессом контейнера).
|
bytes, not by extension.
|
||||||
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
|
- The password is never stored: without it the file cannot be recovered. In the
|
||||||
offline и подбирается без ограничений по времени.
|
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||||
|
`-password-file`, never as an argument (the process list is readable by any
|
||||||
|
process in the container).
|
||||||
|
- Minimum password length matches the administrator password (12): the file
|
||||||
|
sits offline and can be attacked without a time limit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Принятые риски
|
## Accepted risks
|
||||||
|
|
||||||
Принятый риск — решение с условием возврата, а не отложенная задача из
|
An accepted risk is a decision with a condition for revisiting it, not a
|
||||||
дорожной карты.
|
deferred item from the roadmap.
|
||||||
|
|
||||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
Принято сознательно: панель однопользовательская, админ выбирает браузер
|
deliberately: every panel user (global or domain-admin) is an operator who
|
||||||
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|
picks their own browser, not an untrusted party the panel needs to defend
|
||||||
панель. Ужесточение — одна строка в `originAllowed`
|
against, and a strict mode would not "protect" such a client, it would
|
||||||
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
|
simply break the panel in it. Tightening is one line in `originAllowed`
|
||||||
вместо `true` в ветке «нет обоих заголовков».
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
|
instead of `true` in the "neither header present" branch.
|
||||||
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
|
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
revisit is a requirement for protection that holds regardless of the browser,
|
||||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
or a domain-admin population the global administrator does not fully trust
|
||||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
(see the ADR below). A token would not save the panel from XSS inside it
|
||||||
inline-скриптов и inline-стилей.
|
either: code executing in the panel's origin sends the request itself —
|
||||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
templates must contain no inline scripts and no inline styles. Tracked as
|
||||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
[csrf-tokens](roadmap.md#csrf-tokens): implementing that item replaces this
|
||||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
bullet and the ADR below, and dropping it leaves them as the single record of
|
||||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
the decision. The two must not disagree.
|
||||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||||
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
|
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||||
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
|
`data-confirm` prompt handled entirely in
|
||||||
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
|
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
|
||||||
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
|
or blocked the form submits immediately, exactly as it did before the
|
||||||
дочитывается. Остаток — строки, delivery-строки которых потеряны
|
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
|
||||||
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
|
not an authorization boundary — the same origin check and session/RBAC
|
||||||
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
|
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||||
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
|
enhancement means the panel must work with JavaScript off; a
|
||||||
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
|
server-rendered confirmation step would need a second page (or a `?confirm=1`
|
||||||
не может подтвердить, не должна показываться как подтверждённая, а вечный
|
round trip) for every one of these forms, which is what
|
||||||
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
|
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||||
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
|
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||||
читается. См. [architecture.md](architecture.md) § Log tailer.
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
`/data/log` — `2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
|
backup at all, and a permanently undecryptable archive is worse than an
|
||||||
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
|
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||||
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
|
encryption mandatory is a second administrator (at which point "who
|
||||||
оставалась состоянием, а не диагностикой.
|
downloaded it" stops being one person).
|
||||||
|
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||||
|
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||||
|
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||||
|
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||||
|
start. What remains are rows whose delivery lines are lost for good (the log
|
||||||
|
rotated past 14 files while the panel was down, or was deleted): the
|
||||||
|
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||||
|
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
|
||||||
|
the status is a false negative. Accepted deliberately: a delivery the panel
|
||||||
|
cannot confirm must not be shown as confirmed, and a permanent `queued` is
|
||||||
|
indistinguishable from "in flight right now". Reconciliation does not run
|
||||||
|
until the tailer has read the log to the end, and touches nothing if
|
||||||
|
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
|
||||||
|
tailer.
|
||||||
|
- **DMARC aggregate reports are accepted without authentication.** Anyone on
|
||||||
|
the internet can mail a fabricated report (or displace a genuine one — the
|
||||||
|
`UNIQUE(reporter, report_id, domain)` upsert is delete-then-insert) and skew
|
||||||
|
the alignment statistics the panel shows. Inherent to how `rua` works:
|
||||||
|
reporters are arbitrary third parties and the mailbox address is published in
|
||||||
|
DNS. The ingest already constrains the meaningful part — a report cannot be
|
||||||
|
filed under a domain this instance does not host, and the claimed domain is
|
||||||
|
cross-checked against the `+tag` of the delivery address — and the data is
|
||||||
|
advisory statistics only; nothing enforces off it. The trigger to revisit is
|
||||||
|
any feature that lets report contents drive an automatic action (policy
|
||||||
|
changes, rate limiting, alerting an operator into a config change).
|
||||||
|
- **The application auth-IP allow-list is enforced fail-open.** The check runs
|
||||||
|
in the journal milter (`internal/milter/authip.go`) with
|
||||||
|
`default_action=accept` and skips on store errors, so if the milter is down
|
||||||
|
the restriction is not applied; the SASL login itself always succeeds. This
|
||||||
|
is documented in [guide.md](guide.md) as **not an authentication boundary** —
|
||||||
|
it is a blast-radius limiter for a leaked application password, on the same
|
||||||
|
fail-open path as level-2 rate limiting, and a milter outage already stops
|
||||||
|
the journal from recording sends at all. The trigger to revisit is a
|
||||||
|
requirement for the allow-list to hold as a security boundary — at which
|
||||||
|
point it must move to the SASL/smtpd layer and fail closed.
|
||||||
|
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
|
||||||
|
The journal milter cannot observe connection-close directly through
|
||||||
|
go-milter, so if a client disconnects without an explicit `Abort`, its
|
||||||
|
in-memory reservation is released by TTL expiry, not immediately. The map is
|
||||||
|
bounded by key and TTL (currently 10 minutes), so the effect is temporary
|
||||||
|
over-counting on that app/domain key under churn. Accepted deliberately:
|
||||||
|
immediate cleanup would require protocol hooks the current milter interface
|
||||||
|
does not expose; the TTL bound keeps it finite. Trigger to revisit: sustained
|
||||||
|
high-volume false throttling attributable to orphaned reservations.
|
||||||
|
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
|
||||||
|
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||||
|
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||||
|
the file is inaccessible to others. The log holds envelope addresses and
|
||||||
|
client IPs, but neither message bodies nor headers; it is excluded from
|
||||||
|
backups (`log/` is skipped) so that a dump stays state rather than
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
## ADR: CSRF через проверку Origin, без токенов
|
## ADR: CSRF via origin checking, without tokens
|
||||||
|
|
||||||
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
|
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||||
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
|
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||||
инициированного сторонним сайтом в браузере залогиненного админа.
|
page apart from one initiated by a third-party site in a logged-in user's
|
||||||
|
browser. The panel is multi-user since 1.2.0 (a global administrator plus
|
||||||
|
zero or more domain-admin users, each scoped to their assigned domains), but
|
||||||
|
that is an authorization boundary (who can see or change what), not a change
|
||||||
|
to the CSRF threat: the attacker in scope here is still an external site
|
||||||
|
riding a legitimate user's cookie, not one panel user attacking another
|
||||||
|
through the browser.
|
||||||
|
|
||||||
**Решение.** `originAllowed` в
|
**Decision.** `originAllowed` in
|
||||||
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
сессии и встроенных в формы, нет.
|
There are no session-bound tokens embedded in forms. The check applies the same
|
||||||
|
way regardless of the requesting user's role.
|
||||||
|
|
||||||
**Почему не токены.** Панель однопользовательская (один администратор на
|
**Why not tokens.** Cross-user CSRF is not the threat model here: a
|
||||||
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
|
domain-admin's browser sending a request still needs that domain-admin's own
|
||||||
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
|
cookie, so a token would not add a boundary between roles that the
|
||||||
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
|
authorization checks (`Principal.CanAccessDomain`,
|
||||||
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
|
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
|
||||||
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
|
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
|
||||||
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
|
don't already enforce. The remaining case is an external site making a
|
||||||
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
|
logged-in user's browser send a request, which the origin check covers without
|
||||||
обороны, не CSRF-токен.
|
touching a single template.
|
||||||
|
A token would need a hidden field in roughly two dozen forms and
|
||||||
|
synchronisation with every new form, and it would still not protect against
|
||||||
|
XSS inside the panel — code executing in the panel's origin reads the token
|
||||||
|
and sends the request itself. XSS is handled by `html/template` auto-escaping
|
||||||
|
and CSP, so that is a separate line of defence, not a CSRF token.
|
||||||
|
|
||||||
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
(по-настоящему старый браузер или webview с замороженным движком), остаётся
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
"Accepted risks" above. This is a deliberate choice not to break the panel in
|
||||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
браузера, или панель станет многопользовательской.
|
browser behaviour, or domain-admin accounts stop being trusted operators (for
|
||||||
|
example, if a future release lets a global administrator invite domain-admins
|
||||||
|
whose browsers/devices are not vetted) — at that point cross-role request
|
||||||
|
forgery inside the panel would need its own analysis, separate from the
|
||||||
|
external-site case this ADR covers.
|
||||||
|
|
||||||
## Как этот список пополняется
|
## How this list grows
|
||||||
|
|
||||||
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||||
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
closes every finding in one of two ways: a fix before the tag, or an entry here
|
||||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
with its rationale and its condition for revisiting, like the items above.
|
||||||
Третьего варианта («посмотрели и ладно») нет.
|
There is no third option ("we looked at it and moved on").
|
||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
// for application credentials (architecture.md § Mail path). The panel is the
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
// created and removed with the standard saslpasswd2 tool ("эквивалент
|
// created and removed with the standard saslpasswd2 tool.
|
||||||
// saslpasswd2", per the plan).
|
|
||||||
type SASLDB struct {
|
type SASLDB struct {
|
||||||
path string // sasldb2 file, under /data so it survives restarts
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||||
|
|||||||
+25
-12
@@ -166,14 +166,24 @@ func (s *Service) RegeneratePassword(id int64) (string, error) {
|
|||||||
// Delete removes an application: its SASL account, its registry row (and address
|
// Delete removes an application: its SASL account, its registry row (and address
|
||||||
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
|
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
|
||||||
// 7.2.8). The domain and other applications are untouched.
|
// 7.2.8). The domain and other applications are untouched.
|
||||||
|
//
|
||||||
|
// The order matches domain deletion: the SASL account goes first, while the
|
||||||
|
// login is still in the registry. Dropping the row first would, on a
|
||||||
|
// saslpasswd2 failure, leave an account that can still authenticate to Postfix
|
||||||
|
// but that the panel no longer knows about — an orphan no operator can see or
|
||||||
|
// remove. Failing before the row is deleted is recoverable: the application is
|
||||||
|
// still listed and the delete can be retried.
|
||||||
func (s *Service) Delete(id int64) error {
|
func (s *Service) Delete(id int64) error {
|
||||||
a, err := s.store.DeleteApplication(id)
|
a, err := s.store.GetApplication(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.sasl.Delete(a.Login); err != nil {
|
if err := s.sasl.Delete(a.Login); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := s.store.DeleteApplication(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Drop the application's level-2 limit, if any (guide § Rate limiting);
|
// Drop the application's level-2 limit, if any (guide § Rate limiting);
|
||||||
// rate_limits has no cascade of its own.
|
// rate_limits has no cascade of its own.
|
||||||
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||||
@@ -189,17 +199,14 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
|||||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
// SaveRateLimit stores the application-level rate limit (guide § Rate
|
||||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
// limiting). The caller has validated the numbers (security.md); the milter
|
||||||
// is needed.
|
// reads the row live, so no reload is needed.
|
||||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
rl.Scope = store.RateLimitScopeApp
|
||||||
Scope: store.RateLimitScopeApp,
|
rl.RefID = appID
|
||||||
RefID: appID,
|
rl.AllowedIPs = nil
|
||||||
AllowedIPs: ips,
|
return s.store.SetRateLimit(rl)
|
||||||
MaxMessages: maxMessages,
|
|
||||||
WindowSeconds: windowSeconds,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
||||||
@@ -208,6 +215,12 @@ func (s *Service) ClearRateLimit(appID int64) error {
|
|||||||
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAuthIPs sets client IP restriction for an application (guide § Rate
|
||||||
|
// limiting — authorization). The caller has validated the IPs (security.md).
|
||||||
|
func (s *Service) UpdateAuthIPs(appID int64, restrict bool, ips []string) error {
|
||||||
|
return s.store.UpdateApplicationAuthIPs(appID, restrict, ips)
|
||||||
|
}
|
||||||
|
|
||||||
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||||
// domain. It must be called before the domain's registry rows are cascade-
|
// 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
|
// deleted, while the logins are still known (product.md). The registry rows and
|
||||||
|
|||||||
@@ -181,6 +181,37 @@ func TestServiceDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If sasldb2 cannot be updated the application must stay in the registry: an
|
||||||
|
// account that still authenticates but has no panel row is invisible to the
|
||||||
|
// operator and cannot be deleted again.
|
||||||
|
func TestServiceDeleteKeepsRowWhenSASLFails(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.failNext = true // saslpasswd2 -d fails
|
||||||
|
if err := svc.Delete(a.ID); err == nil {
|
||||||
|
t.Fatal("Delete reported success although the SASL account was not removed")
|
||||||
|
}
|
||||||
|
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||||
|
if len(apps) != 1 {
|
||||||
|
t.Fatalf("registry row dropped while the SASL account can still authenticate: %+v", apps)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; !ok {
|
||||||
|
t.Fatal("SASL account gone despite the failure — the harness no longer proves the ordering")
|
||||||
|
}
|
||||||
|
// The delete is retryable now that the row is still there.
|
||||||
|
if err := svc.Delete(a.ID); err != nil {
|
||||||
|
t.Fatalf("retried Delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := rec.set["app1"]; ok {
|
||||||
|
t.Error("SASL account not deleted on retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceUpdateMode(t *testing.T) {
|
func TestServiceUpdateMode(t *testing.T) {
|
||||||
svc, st, _, maps := newServiceHarness(t)
|
svc, st, _, maps := newServiceHarness(t)
|
||||||
d := addDomain(t, st, "example.com")
|
d := addDomain(t, st, "example.com")
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package atomicfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Write 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.
|
||||||
|
func Write(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
|
||||||
|
}
|
||||||
+129
-52
@@ -1,18 +1,26 @@
|
|||||||
// Package backup implements SelfPost's full-server backup and the restore
|
// Package backup implements SelfPost's full-server backup and the restore
|
||||||
// version guard (architecture.md § Persistence). A full backup is a
|
// version guard (architecture.md § Persistence). A full backup is a
|
||||||
// gzip-compressed tar of the consolidated persistent state under /data — the
|
// gzip-compressed tar of the consolidated persistent state under /data — the
|
||||||
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
|
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
|
||||||
// SASL database — plus a manifest recording the SelfPost version that produced
|
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
|
||||||
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
|
// certs/ from the operator's deploy directory, and a manifest recording the
|
||||||
// queue are deliberately excluded (architecture.md § Persistence).
|
// SelfPost version that produced it. Postfix delivery logs under log/ are
|
||||||
|
// excluded (diagnostics, not state).
|
||||||
//
|
//
|
||||||
// Restore is intentionally not a separate code path: a backup is extracted
|
// Restore is not a separate code path in the panel: a backup is extracted into
|
||||||
// into the /data bind mount before first start, and the panel regenerates
|
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
|
||||||
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal
|
// before first start. The archive carries everything needed to bring the
|
||||||
// start. The only restore-specific step is CheckRestore, which refuses to boot
|
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
|
||||||
// if the manifest's version does not match the running binary, so
|
// so the operator only adjusts hostname or proxy settings on a new host. The
|
||||||
|
// restore-specific steps the panel runs are 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
|
// schema/format skew between versions cannot silently corrupt state
|
||||||
// (architecture.md § Persistence).
|
// (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
|
||||||
|
// and the Postfix sender map from SQLite on that first boot, so any drift
|
||||||
|
// between the archive and the database is healed before mail flows. If
|
||||||
|
// on-disk state drifts again later — for example after a manual edit under
|
||||||
|
// /data — the Status page's "Reload configuration" button runs the same
|
||||||
|
// Resync on demand.
|
||||||
package backup
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -34,11 +42,21 @@ import (
|
|||||||
// FormatFull identifies a full-server backup manifest.
|
// FormatFull identifies a full-server backup manifest.
|
||||||
const FormatFull = "selfpost-full-backup"
|
const FormatFull = "selfpost-full-backup"
|
||||||
|
|
||||||
// ManifestName is the manifest's filename, both inside the archive and, after a
|
// ManifestName is the manifest's filename inside the data directory. After
|
||||||
// restore extraction, at the root of the data directory where CheckRestore
|
// restore extraction it lives at data/manifest.json under the project root;
|
||||||
// looks for it.
|
// CheckRestore reads it from the /data bind mount.
|
||||||
const ManifestName = "manifest.json"
|
const ManifestName = "manifest.json"
|
||||||
|
|
||||||
|
// DataArchivePrefix is the path prefix for every /data entry in the archive.
|
||||||
|
const DataArchivePrefix = "data/"
|
||||||
|
|
||||||
|
// ComposeFileName and EnvFileName are required deploy files at the archive root.
|
||||||
|
const (
|
||||||
|
ComposeFileName = "docker-compose.yml"
|
||||||
|
EnvFileName = ".env"
|
||||||
|
CertsDirName = "certs"
|
||||||
|
)
|
||||||
|
|
||||||
// Manifest is the small JSON document embedded in every backup archive. Its
|
// 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
|
// 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
|
// boot a data directory whose manifest version does not match its own binary
|
||||||
@@ -52,21 +70,23 @@ type Manifest struct {
|
|||||||
// Params configures a backup. DataDir is the consolidated state root (/data);
|
// Params configures a backup. DataDir is the consolidated state root (/data);
|
||||||
// DBPath is the live SQLite file within it, snapshotted consistently rather than
|
// 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
|
// copied byte-for-byte while it may be mid-write; Version is stamped into the
|
||||||
// manifest.
|
// manifest; DeployRoot is the host project directory mounted read-only (holds
|
||||||
|
// docker-compose.yml, .env, and optionally certs/). OnWarn is called for
|
||||||
|
// non-fatal issues such as a missing certs/ directory.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
DataDir string
|
DataDir string
|
||||||
DBPath string
|
DBPath string
|
||||||
Version string
|
Version string
|
||||||
|
DeployRoot string
|
||||||
|
OnWarn func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludedFromArchive lists the data-directory entries a backup never carries.
|
// excludedFromArchive lists the data-directory entries a backup never carries.
|
||||||
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
||||||
// written under the canonical name; the setup token is transient bootstrap
|
// 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
|
// 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
|
// fresh one is written instead); a "tls" directory under /data is skipped when
|
||||||
// certificates, which are explicitly out of scope for a SelfPost backup
|
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
|
||||||
// (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
|
// delivery log plus its fourteen rotated files, which is diagnostic output, not
|
||||||
// state to restore, and by far the largest thing under /data.
|
// state to restore, and by far the largest thing under /data.
|
||||||
var excludedFromArchive = map[string]bool{
|
var excludedFromArchive = map[string]bool{
|
||||||
@@ -80,16 +100,22 @@ var excludedFromArchive = map[string]bool{
|
|||||||
ManifestName: true,
|
ManifestName: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create writes a gzip-compressed tar backup to w. Archive entries are named
|
// Create writes a gzip-compressed tar backup to w. Archive layout:
|
||||||
// relative to DataDir, so extracting the archive into the /data bind mount
|
//
|
||||||
// reconstructs the state in place (architecture.md § Persistence). The SQLite
|
// data/manifest.json, data/selfpost.db, data/<rest of /data>
|
||||||
// database is added as a consistent snapshot under "selfpost.db"; everything
|
// docker-compose.yml, .env, certs/...
|
||||||
// else under DataDir is copied as-is except the entries in
|
//
|
||||||
// excludedFromArchive.
|
// Extract the archive into an empty project directory, then docker compose up.
|
||||||
func Create(w io.Writer, p Params) error {
|
func Create(w io.Writer, p Params) error {
|
||||||
if p.DataDir == "" || p.DBPath == "" {
|
if p.DataDir == "" || p.DBPath == "" {
|
||||||
return fmt.Errorf("backup: DataDir and DBPath are required")
|
return fmt.Errorf("backup: DataDir and DBPath are required")
|
||||||
}
|
}
|
||||||
|
if p.DeployRoot == "" {
|
||||||
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
|
}
|
||||||
|
if err := validateDeployRoot(p.DeployRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,17 +135,21 @@ func Create(w io.Writer, p Params) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backup: encode manifest: %w", err)
|
return fmt.Errorf("backup: encode manifest: %w", err)
|
||||||
}
|
}
|
||||||
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
|
if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The consistent SQLite snapshot, under the canonical filename the panel
|
// 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).
|
// 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 {
|
if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := addTree(tw, p.DataDir); err != nil {
|
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,33 +162,79 @@ func Create(w io.Writer, p Params) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTree walks dataDir and adds every regular file (and directory, to preserve
|
// ValidateDeployRoot checks that the operator project directory is mounted and
|
||||||
// empty ones and modes) to tw under its path relative to dataDir, skipping the
|
// contains the files a full backup requires. Call before streaming a response.
|
||||||
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
|
func ValidateDeployRoot(deployRoot string) error {
|
||||||
// skipped: /data holds none in normal operation, and copying them into a backup
|
if deployRoot == "" {
|
||||||
// would be meaningless or unsafe.
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
func addTree(tw *tar.Writer, dataDir string) error {
|
}
|
||||||
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
|
return validateDeployRoot(deployRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDeployRoot(deployRoot string) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
path := filepath.Join(deployRoot, name)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
src := filepath.Join(deployRoot, name)
|
||||||
|
info, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: stat deploy file %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
|
||||||
|
}
|
||||||
|
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certsDir := filepath.Join(deployRoot, CertsDirName)
|
||||||
|
if _, err := os.Stat(certsDir); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if onWarn != nil {
|
||||||
|
onWarn("certs/ not found in deploy root; backup will not include TLS material")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
|
||||||
|
}
|
||||||
|
return addTree(tw, certsDir, CertsDirName+"/", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTree walks root and adds every regular file (and directory, to preserve
|
||||||
|
// empty ones and modes) to tw under archivePrefix + path relative to root.
|
||||||
|
// When exclude is non-nil, top-level names relative to root are skipped.
|
||||||
|
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
|
||||||
|
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
|
||||||
|
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(dataDir, path)
|
rel, err := filepath.Rel(root, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rel == "." {
|
if rel == "." {
|
||||||
return nil // the data root itself is implicit
|
return nil
|
||||||
}
|
}
|
||||||
// filepath.Rel yields OS separators; tar names use forward slashes.
|
name := archivePrefix + filepath.ToSlash(rel)
|
||||||
name := filepath.ToSlash(rel)
|
if exclude != nil {
|
||||||
// Exclude by top-level name (the live DB, setup token and stale manifest
|
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||||
// all live at the data root).
|
if exclude[top] {
|
||||||
if excludedFromArchive[name] {
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
return fs.SkipDir
|
return fs.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info, err := d.Info()
|
info, err := d.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error {
|
|||||||
case info.Mode().IsRegular():
|
case info.Mode().IsRegular():
|
||||||
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||||
default:
|
default:
|
||||||
return nil // skip symlinks/sockets/devices
|
return nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -264,33 +340,34 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
|||||||
// directory), its version must match binaryVersion or the panel refuses to
|
// 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
|
// 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
|
// 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
|
// never blocks a later in-place image upgrade, and restored is true so the
|
||||||
// normal case and returns nil.
|
// caller can heal drifted daemon maps once. Absence of the manifest is the
|
||||||
func CheckRestore(manifestPath, binaryVersion string) error {
|
// normal case and returns restored == false with a nil error.
|
||||||
|
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
|
||||||
data, err := os.ReadFile(manifestPath)
|
data, err := os.ReadFile(manifestPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil // ordinary start, not a restore
|
return false, nil // ordinary start, not a restore
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backup: read restore manifest: %w", err)
|
return false, fmt.Errorf("backup: read restore manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var m Manifest
|
var m Manifest
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
|
return false, fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
|
||||||
}
|
}
|
||||||
if m.Format != FormatFull {
|
if m.Format != FormatFull {
|
||||||
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
|
return false, fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
|
||||||
}
|
}
|
||||||
if m.Version != binaryVersion {
|
if m.Version != binaryVersion {
|
||||||
return fmt.Errorf(
|
return false, fmt.Errorf(
|
||||||
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
|
"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)
|
m.Version, binaryVersion, m.Version)
|
||||||
}
|
}
|
||||||
// Version matches: consume the manifest so subsequent normal starts (and
|
// Version matches: consume the manifest so subsequent normal starts (and
|
||||||
// in-place upgrades) are not gated by it.
|
// in-place upgrades) are not gated by it.
|
||||||
if err := os.Remove(manifestPath); err != nil {
|
if err := os.Remove(manifestPath); err != nil {
|
||||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
return false, fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
||||||
// DKIM key, SASL and transient files a backup must include or exclude.
|
// DKIM key, SASL and transient files a backup must include or exclude. It also
|
||||||
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
// seeds a deploy root beside data/ with compose, .env, and certs/.
|
||||||
|
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir = t.TempDir()
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
dbPath = filepath.Join(dataDir, "selfpost.db")
|
dbPath = filepath.Join(dataDir, "selfpost.db")
|
||||||
|
|
||||||
st, err := store.Open(dbPath)
|
st, err := store.Open(dbPath)
|
||||||
@@ -35,6 +40,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
|||||||
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
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, "sasl", "sasldb2"), "SASLDB")
|
||||||
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
|
||||||
// Transient files that must NOT be archived.
|
// Transient files that must NOT be archived.
|
||||||
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
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-wal"), "wal")
|
||||||
@@ -43,7 +49,11 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
|||||||
// state, and the bulkiest thing under /data.
|
// 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"), "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")
|
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
||||||
return dataDir, dbPath
|
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
return dataDir, dbPath, deployRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFile(t *testing.T, path, content string) {
|
func writeFile(t *testing.T, path, content string) {
|
||||||
@@ -87,21 +97,26 @@ func readArchive(t *testing.T, data []byte) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||||
dataDir, dbPath := seedDataDir(t)
|
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
|
||||||
t.Fatalf("Create: %v", err)
|
t.Fatalf("Create: %v", err)
|
||||||
}
|
}
|
||||||
files := readArchive(t, buf.Bytes())
|
files := readArchive(t, buf.Bytes())
|
||||||
|
|
||||||
// Present.
|
// Present.
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
ManifestName,
|
DataArchivePrefix + ManifestName,
|
||||||
"selfpost.db",
|
DataArchivePrefix + "selfpost.db",
|
||||||
"opendkim/keys/example.com/selfpost.private",
|
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||||
"sasl/sasldb2",
|
DataArchivePrefix + "sasl/sasldb2",
|
||||||
"postfix/sender_login_maps",
|
DataArchivePrefix + "postfix/sender_login_maps",
|
||||||
|
DataArchivePrefix + "postfix/queue/deferred/sample",
|
||||||
|
ComposeFileName,
|
||||||
|
EnvFileName,
|
||||||
|
CertsDirName + "/fullchain.pem",
|
||||||
|
CertsDirName + "/privkey.pem",
|
||||||
} {
|
} {
|
||||||
if _, ok := files[name]; !ok {
|
if _, ok := files[name]; !ok {
|
||||||
t.Errorf("archive missing %s", name)
|
t.Errorf("archive missing %s", name)
|
||||||
@@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Excluded.
|
// Excluded.
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"setup-token", "selfpost.db-wal", "selfpost.db-shm",
|
DataArchivePrefix + "setup-token",
|
||||||
"log/mail.log", "log/mail.log.1",
|
DataArchivePrefix + "selfpost.db-wal",
|
||||||
|
DataArchivePrefix + "selfpost.db-shm",
|
||||||
|
DataArchivePrefix + "log/mail.log",
|
||||||
|
DataArchivePrefix + "log/mail.log.1",
|
||||||
} {
|
} {
|
||||||
if _, ok := files[name]; ok {
|
if _, ok := files[name]; ok {
|
||||||
t.Errorf("archive should not contain %s", name)
|
t.Errorf("archive should not contain %s", name)
|
||||||
@@ -119,7 +137,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
|
|
||||||
// Manifest is well-formed and carries the version.
|
// Manifest is well-formed and carries the version.
|
||||||
var m Manifest
|
var m Manifest
|
||||||
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
|
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
|
||||||
t.Fatalf("manifest json: %v", err)
|
t.Fatalf("manifest json: %v", err)
|
||||||
}
|
}
|
||||||
if m.Format != FormatFull || m.Version != "1.2.3" {
|
if m.Format != FormatFull || m.Version != "1.2.3" {
|
||||||
@@ -128,7 +146,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
|
|
||||||
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
||||||
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
||||||
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
|
if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
|
||||||
t.Fatalf("write snapshot: %v", err)
|
t.Fatalf("write snapshot: %v", err)
|
||||||
}
|
}
|
||||||
st, err := store.Open(snapPath)
|
st, err := store.Open(snapPath)
|
||||||
@@ -145,6 +163,14 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRequiresDeployRoot(t *testing.T) {
|
||||||
|
dataDir, dbPath, _ := seedDataDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
|
||||||
|
t.Fatal("Create without DeployRoot succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeManifest(t *testing.T, dir, format, version string) string {
|
func writeManifest(t *testing.T, dir, format, version string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, ManifestName)
|
path := filepath.Join(dir, ManifestName)
|
||||||
@@ -156,17 +182,25 @@ func writeManifest(t *testing.T, dir, format, version string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
|
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
|
||||||
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil {
|
restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0")
|
||||||
|
if err != nil {
|
||||||
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
|
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore with no manifest reported a restore")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||||
if err := CheckRestore(path, "1.0.0"); err != nil {
|
restored, err := CheckRestore(path, "1.0.0")
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("CheckRestore matching = %v, want nil", err)
|
t.Fatalf("CheckRestore matching = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
if !restored {
|
||||||
|
t.Fatal("CheckRestore matching did not report a restore")
|
||||||
|
}
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
|
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -175,10 +209,13 @@ func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
|
|||||||
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
|
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
path := writeManifest(t, dir, FormatFull, "1.0.0")
|
||||||
err := CheckRestore(path, "2.0.0")
|
restored, err := CheckRestore(path, "2.0.0")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("CheckRestore mismatch = nil, want error")
|
t.Fatal("CheckRestore mismatch = nil, want error")
|
||||||
}
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore mismatch reported a restore")
|
||||||
|
}
|
||||||
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
|
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
|
||||||
t.Errorf("error should name both versions: %v", err)
|
t.Errorf("error should name both versions: %v", err)
|
||||||
}
|
}
|
||||||
@@ -190,7 +227,11 @@ func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
|
|||||||
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
|
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := writeManifest(t, dir, "something-else", "1.0.0")
|
path := writeManifest(t, dir, "something-else", "1.0.0")
|
||||||
if err := CheckRestore(path, "1.0.0"); err == nil {
|
restored, err := CheckRestore(path, "1.0.0")
|
||||||
|
if err == nil {
|
||||||
t.Error("CheckRestore accepted a non-backup manifest")
|
t.Error("CheckRestore accepted a non-backup manifest")
|
||||||
}
|
}
|
||||||
|
if restored {
|
||||||
|
t.Error("CheckRestore wrong format reported a restore")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package configsafe holds the defence-in-depth check that every generated
|
||||||
|
// configuration file shares: a value about to be written into a table line must
|
||||||
|
// be non-empty and must not carry a character that could end the field, end the
|
||||||
|
// line, or otherwise change what the file means (security.md).
|
||||||
|
//
|
||||||
|
// The forbidden set stays with the caller rather than living here, because what
|
||||||
|
// breaks a line differs per format — a comma separates values in a Postfix map,
|
||||||
|
// a colon separates fields in an OpenDKIM table — and the set belongs next to
|
||||||
|
// the writer it protects, where a reviewer of that writer can see it.
|
||||||
|
package configsafe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token reports whether value is safe to interpolate into a config line. what
|
||||||
|
// names the field for the error message ("domain", "login", …) and forbidden
|
||||||
|
// lists the characters the format cannot carry.
|
||||||
|
func Token(what, value, forbidden string) error {
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("empty %s", what)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(value, forbidden) {
|
||||||
|
return fmt.Errorf("unsafe character in %s %q", what, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package configsafe
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The check is the last line of defence before a value reaches a config file,
|
||||||
|
// so it must reject both halves of the problem: an empty value that would write
|
||||||
|
// a malformed line, and any character from the caller's forbidden set.
|
||||||
|
func TestToken(t *testing.T) {
|
||||||
|
if err := Token("domain", "example.com", " \t\r\n,\\"); err != nil {
|
||||||
|
t.Fatalf("clean value rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := Token("domain", "", " "); err == nil {
|
||||||
|
t.Fatal("empty value accepted")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"a b", "a\tb", "a\rb", "a\nb", "a,b", "a\\b"} {
|
||||||
|
if err := Token("domain", bad, " \t\r\n,\\"); err == nil {
|
||||||
|
t.Errorf("Token(%q) = nil, want error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A character outside the caller's set is that format's business, not this
|
||||||
|
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash.
|
||||||
|
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
|
||||||
|
t.Errorf("character outside the forbidden set rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
|
||||||
|
const DefaultLocalPart = "dmarc-reports"
|
||||||
|
|
||||||
|
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
||||||
|
func HostedReportAddress(hostname, domain string) string {
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
return DefaultLocalPart + "+" + domain + "@" + hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
|
||||||
|
func DefaultHostedReportAddress(hostname string) string {
|
||||||
|
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
||||||
|
func IsHostedOnHostname(addr, hostname string) bool {
|
||||||
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
if hostname == "" || !strings.Contains(addr, "@") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
return addr[at+1:] == hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaggedDomain returns the domain a hosted report address is tagged for
|
||||||
|
// (dmarc-reports+<domain>@<hostname> → <domain>), or "" when addr carries no
|
||||||
|
// such tag. Used to check a report's claimed domain against the address it
|
||||||
|
// actually arrived at.
|
||||||
|
func TaggedDomain(addr string) string {
|
||||||
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
local := addr[:at]
|
||||||
|
plus := strings.Index(local, "+")
|
||||||
|
if plus < 0 || local[:plus] != DefaultLocalPart {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return local[plus+1:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
|
||||||
|
// aggregate attachment it finds.
|
||||||
|
//
|
||||||
|
// The report XML is unauthenticated input: anyone who can reach an allow-listed
|
||||||
|
// rua= address can post one, and the address form is predictable. So the domain
|
||||||
|
// the report claims is checked twice before anything is stored — it must be a
|
||||||
|
// domain this relay actually sends for, and, when the report arrived at a
|
||||||
|
// per-domain hosted address (dmarc-reports+<domain>@<hostname>), it must be the
|
||||||
|
// domain that address is tagged for. Without this a stranger could file reports
|
||||||
|
// under someone else's domain, skew the panel's alignment figures, and push
|
||||||
|
// genuine reports out through the retention cap.
|
||||||
|
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read message: %w", err)
|
||||||
|
}
|
||||||
|
payload, err := extractAggregatePayload(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := ParseAggregate(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
recipient = strings.ToLower(strings.TrimSpace(recipient))
|
||||||
|
if err := checkReportDomain(st, parsed.Domain, recipient); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rep := store.DMARCReport{
|
||||||
|
Domain: parsed.Domain,
|
||||||
|
Reporter: parsed.Reporter,
|
||||||
|
ReportID: parsed.ReportID,
|
||||||
|
PeriodBegin: parsed.PeriodBegin,
|
||||||
|
PeriodEnd: parsed.PeriodEnd,
|
||||||
|
ReceivedAt: receivedAt.UTC(),
|
||||||
|
ContactEmail: parsed.ContactEmail,
|
||||||
|
PolicyP: parsed.PolicyP,
|
||||||
|
PolicySP: parsed.PolicySP,
|
||||||
|
PolicyPct: parsed.PolicyPct,
|
||||||
|
PolicyADKIM: parsed.PolicyADKIM,
|
||||||
|
PolicyASPF: parsed.PolicyASPF,
|
||||||
|
PassCount: parsed.PassCount,
|
||||||
|
FailCount: parsed.FailCount,
|
||||||
|
Recipient: recipient,
|
||||||
|
}
|
||||||
|
for _, rec := range parsed.Records {
|
||||||
|
rep.Records = append(rep.Records, store.DMARCReportRecord{
|
||||||
|
SourceIP: rec.SourceIP,
|
||||||
|
Count: rec.Count,
|
||||||
|
Disposition: rec.Disposition,
|
||||||
|
SPFResult: rec.SPFResult,
|
||||||
|
DKIMResult: rec.DKIMResult,
|
||||||
|
HeaderFrom: rec.HeaderFrom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := st.InsertDMARCReport(rep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return st.PruneDMARCReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkReportDomain refuses a report whose policy domain is not one of this
|
||||||
|
// relay's sending domains, or that contradicts the +tag of the address it was
|
||||||
|
// delivered to.
|
||||||
|
func checkReportDomain(st *store.Store, domain, recipient string) error {
|
||||||
|
if domain == "" {
|
||||||
|
return fmt.Errorf("dmarc report: no policy domain")
|
||||||
|
}
|
||||||
|
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
|
||||||
|
return fmt.Errorf("dmarc report: domain %q does not match the address it was sent to (%s)", domain, recipient)
|
||||||
|
}
|
||||||
|
ok, err := st.DomainExists(domain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("dmarc report: %q is not a domain configured on this relay", domain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAggregatePayload(raw []byte) ([]byte, error) {
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse message: %w", err)
|
||||||
|
}
|
||||||
|
ct := msg.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
|
||||||
|
}
|
||||||
|
mediaType, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("content-type: %w", err)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return nil, fmt.Errorf("multipart without boundary")
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(msg.Body, boundary)
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("multipart: %w", err)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(part.FileName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.ToLower(part.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !looksLikeAggregate(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) > 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no aggregate attachment found")
|
||||||
|
}
|
||||||
|
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeAggregate(name string) bool {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||||
|
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
|
||||||
|
var body io.Reader = r
|
||||||
|
switch encoding {
|
||||||
|
case "base64":
|
||||||
|
body = base64.NewDecoder(base64.StdEncoding, r)
|
||||||
|
case "quoted-printable":
|
||||||
|
body = quotedprintable.NewReader(r)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read part body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
||||||
|
type ParsedReport struct {
|
||||||
|
Reporter string
|
||||||
|
ReportID string
|
||||||
|
ContactEmail string
|
||||||
|
PeriodBegin time.Time
|
||||||
|
PeriodEnd time.Time
|
||||||
|
Domain string
|
||||||
|
PolicyP string
|
||||||
|
PolicySP string
|
||||||
|
PolicyPct int
|
||||||
|
PolicyADKIM string
|
||||||
|
PolicyASPF string
|
||||||
|
Records []ParsedRecord
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedRecord is one <record> row from the aggregate XML.
|
||||||
|
type ParsedRecord struct {
|
||||||
|
SourceIP string
|
||||||
|
Count int
|
||||||
|
Disposition string
|
||||||
|
SPFResult string
|
||||||
|
DKIMResult string
|
||||||
|
HeaderFrom string
|
||||||
|
}
|
||||||
|
|
||||||
|
type feedbackXML struct {
|
||||||
|
XMLName xml.Name `xml:"feedback"`
|
||||||
|
Metadata struct {
|
||||||
|
OrgName string `xml:"org_name"`
|
||||||
|
Email string `xml:"email"`
|
||||||
|
ReportID string `xml:"report_id"`
|
||||||
|
DateRange struct {
|
||||||
|
Begin int64 `xml:"begin"`
|
||||||
|
End int64 `xml:"end"`
|
||||||
|
} `xml:"date_range"`
|
||||||
|
} `xml:"report_metadata"`
|
||||||
|
Policy struct {
|
||||||
|
Domain string `xml:"domain"`
|
||||||
|
P string `xml:"p"`
|
||||||
|
SP string `xml:"sp"`
|
||||||
|
Pct int `xml:"pct"`
|
||||||
|
ADKIM string `xml:"adkim"`
|
||||||
|
ASPF string `xml:"aspf"`
|
||||||
|
} `xml:"policy_published"`
|
||||||
|
Records []struct {
|
||||||
|
Row struct {
|
||||||
|
SourceIP string `xml:"source_ip"`
|
||||||
|
Count int `xml:"count"`
|
||||||
|
Policy struct {
|
||||||
|
Disposition string `xml:"disposition"`
|
||||||
|
DKIM string `xml:"dkim"`
|
||||||
|
SPF string `xml:"spf"`
|
||||||
|
} `xml:"policy_evaluated"`
|
||||||
|
} `xml:"row"`
|
||||||
|
Identifiers struct {
|
||||||
|
HeaderFrom string `xml:"header_from"`
|
||||||
|
} `xml:"identifiers"`
|
||||||
|
} `xml:"record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
|
||||||
|
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||||
|
data, err := decompress(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ParsedReport{}, err
|
||||||
|
}
|
||||||
|
var doc feedbackXML
|
||||||
|
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||||
|
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
|
||||||
|
}
|
||||||
|
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
||||||
|
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
||||||
|
}
|
||||||
|
out := ParsedReport{
|
||||||
|
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
|
||||||
|
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
|
||||||
|
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
|
||||||
|
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
||||||
|
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
||||||
|
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
|
||||||
|
PolicyP: strings.TrimSpace(doc.Policy.P),
|
||||||
|
PolicySP: strings.TrimSpace(doc.Policy.SP),
|
||||||
|
PolicyPct: doc.Policy.Pct,
|
||||||
|
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
|
||||||
|
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
|
||||||
|
}
|
||||||
|
if out.PolicyPct == 0 {
|
||||||
|
out.PolicyPct = 100
|
||||||
|
}
|
||||||
|
for _, rec := range doc.Records {
|
||||||
|
row := ParsedRecord{
|
||||||
|
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
|
||||||
|
Count: rec.Row.Count,
|
||||||
|
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
|
||||||
|
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
|
||||||
|
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
|
||||||
|
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
|
||||||
|
}
|
||||||
|
if row.Count <= 0 {
|
||||||
|
row.Count = 1
|
||||||
|
}
|
||||||
|
if dmarcRecordPasses(row) {
|
||||||
|
out.PassCount += row.Count
|
||||||
|
} else {
|
||||||
|
out.FailCount += row.Count
|
||||||
|
}
|
||||||
|
out.Records = append(out.Records, row)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||||
|
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAggregateXML bounds the decompressed payload, so a small archive cannot
|
||||||
|
// expand into an unbounded allocation.
|
||||||
|
const maxAggregateXML = 8 << 20
|
||||||
|
|
||||||
|
// decompress unwraps the container the reporter used. Both are seen in the
|
||||||
|
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
|
||||||
|
// endpoints) ship it as a single-entry zip.
|
||||||
|
func decompress(raw []byte) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
|
||||||
|
return gunzip(raw)
|
||||||
|
case len(raw) >= 4 && raw[0] == 'P' && raw[1] == 'K' && raw[2] == 0x03 && raw[3] == 0x04:
|
||||||
|
return unzip(raw)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gunzip(raw []byte) ([]byte, error) {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(zr, maxAggregateXML))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc gzip read: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unzip returns the first .xml entry of a zip archive. Directories and any
|
||||||
|
// other members are skipped rather than trusted.
|
||||||
|
func unzip(raw []byte) ([]byte, error) {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc zip: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc zip open %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc zip read %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("dmarc zip: no xml entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TightenPolicyHint summarises whether raising p= looks reasonable.
|
||||||
|
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
||||||
|
total := pass + fail
|
||||||
|
if total == 0 {
|
||||||
|
return "No messages in the reporting window yet."
|
||||||
|
}
|
||||||
|
failPct := float64(fail) * 100 / float64(total)
|
||||||
|
if fail > 0 {
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.FailCount > 0 && !s.ThisRelay {
|
||||||
|
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failPct <= 2 && pass > 0 {
|
||||||
|
return "Alignment looks strong. Tightening p= may be reasonable."
|
||||||
|
}
|
||||||
|
if fail > 0 {
|
||||||
|
return "Some failures remain. Review sources before tightening p=."
|
||||||
|
}
|
||||||
|
return "Alignment looks clean for this window."
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceHint is a panel-facing rollup row with relay detection.
|
||||||
|
type SourceHint struct {
|
||||||
|
SourceIP string
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
ThisRelay bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>google.com</org_name>
|
||||||
|
<email>noreply@google.com</email>
|
||||||
|
<report_id>12345</report_id>
|
||||||
|
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<p>none</p><sp>none</sp><pct>100</pct>
|
||||||
|
<adkim>r</adkim><aspf>r</aspf>
|
||||||
|
</policy_published>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>203.0.113.10</source_ip>
|
||||||
|
<count>10</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>pass</dkim><spf>pass</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers><header_from>example.com</header_from></identifiers>
|
||||||
|
</record>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>198.51.100.1</source_ip>
|
||||||
|
<count>2</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>fail</dkim><spf>fail</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers><header_from>example.com</header_from></identifiers>
|
||||||
|
</record>
|
||||||
|
</feedback>`
|
||||||
|
|
||||||
|
func TestParseAggregateGzip(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
if _, err := zw.Write([]byte(sampleXML)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ParseAggregate(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAggregate: %v", err)
|
||||||
|
}
|
||||||
|
if got.Domain != "example.com" || got.Reporter != "google.com" {
|
||||||
|
t.Fatalf("metadata = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PassCount != 10 || got.FailCount != 2 {
|
||||||
|
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
|
||||||
|
}
|
||||||
|
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
|
||||||
|
t.Fatalf("period begin = %v", got.PeriodBegin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestMessageStoresReport(t *testing.T) {
|
||||||
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gz bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&gz)
|
||||||
|
_, _ = zw.Write([]byte(sampleXML))
|
||||||
|
_ = zw.Close()
|
||||||
|
|
||||||
|
msg := bytes.NewBufferString(
|
||||||
|
"From: noreply@google.com\r\n" +
|
||||||
|
"To: dmarc-reports@mail.example.com\r\n" +
|
||||||
|
"Subject: Report\r\n" +
|
||||||
|
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||||
|
"\r\n",
|
||||||
|
)
|
||||||
|
// append raw gzip for simple single-part test
|
||||||
|
msg.Write(gz.Bytes())
|
||||||
|
|
||||||
|
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("IngestMessage: %v", err)
|
||||||
|
}
|
||||||
|
list, err := st.ListDMARCReports(nil, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("reports = %d", len(list))
|
||||||
|
}
|
||||||
|
if list[0].PassCount != 10 {
|
||||||
|
t.Fatalf("pass = %d", list[0].PassCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostedReportAddress(t *testing.T) {
|
||||||
|
got := HostedReportAddress("mail.example.com", "Example.COM")
|
||||||
|
want := "dmarc-reports+example.com@mail.example.com"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
|
||||||
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if err := IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()); err == nil {
|
||||||
|
t.Fatal("report for an unconfigured domain was accepted")
|
||||||
|
}
|
||||||
|
list, err := st.ListDMARCReports(nil, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("stored %d report(s) for an unconfigured domain", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A report claiming example.com must not be accepted at the address tagged for
|
||||||
|
// another domain, even when both domains are configured here.
|
||||||
|
func TestIngestMessageRejectsTagMismatch(t *testing.T) {
|
||||||
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddDomain("other.example", "selfpost"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)),
|
||||||
|
"dmarc-reports+other.example@mail.example.com", time.Now().UTC())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("report was accepted at an address tagged for another domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaggedDomain(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"dmarc-reports+example.com@mail.example.com": "example.com",
|
||||||
|
"dmarc-reports@mail.example.com": "",
|
||||||
|
"postmaster+example.com@mail.example.com": "",
|
||||||
|
"not-an-address": "",
|
||||||
|
}
|
||||||
|
for addr, want := range cases {
|
||||||
|
if got := TaggedDomain(addr); got != want {
|
||||||
|
t.Errorf("TaggedDomain(%q) = %q, want %q", addr, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAggregateFromZip(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
w, err := zw.Create("report.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(sampleXML)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseAggregate(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAggregate(zip): %v", err)
|
||||||
|
}
|
||||||
|
if got.Domain != "example.com" || got.PassCount != 10 {
|
||||||
|
t.Fatalf("zip report = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gzipMessage wraps xml in the single-part gzip message the ingest pipe sees.
|
||||||
|
func gzipMessage(t *testing.T, xml string) string {
|
||||||
|
t.Helper()
|
||||||
|
var gz bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&gz)
|
||||||
|
if _, err := zw.Write([]byte(xml)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return "From: noreply@google.com\r\n" +
|
||||||
|
"To: dmarc-reports@mail.example.com\r\n" +
|
||||||
|
"Subject: Report\r\n" +
|
||||||
|
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||||
|
"\r\n" + gz.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
|
||||||
|
// gzip/XML is parsed, and summaries land in SQLite for the panel.
|
||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maps is the Postfix manager surface the DMARC service needs.
|
||||||
|
type Maps interface {
|
||||||
|
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
|
||||||
|
type Service struct {
|
||||||
|
store *store.Store
|
||||||
|
maps Maps
|
||||||
|
hostname string
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService builds the DMARC ingest service.
|
||||||
|
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
|
||||||
|
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether DMARC ingest is active in this deployment.
|
||||||
|
func (s *Service) Enabled() bool { return s.enabled }
|
||||||
|
|
||||||
|
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
|
||||||
|
func (s *Service) Resync() error {
|
||||||
|
if !s.enabled {
|
||||||
|
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
|
||||||
|
}
|
||||||
|
addrs, err := s.AllowedRecipients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
domains := recipientDomains(addrs)
|
||||||
|
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
|
||||||
|
Recipients: addrs,
|
||||||
|
Domains: domains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
|
||||||
|
func (s *Service) AllowedRecipients() ([]string, error) {
|
||||||
|
if !s.enabled || s.hostname == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
profile, err := s.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
add := func(addr string) {
|
||||||
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[addr] = true
|
||||||
|
out = append(out, addr)
|
||||||
|
}
|
||||||
|
add(profile)
|
||||||
|
if profile == "" {
|
||||||
|
add(DefaultHostedReportAddress(s.hostname))
|
||||||
|
}
|
||||||
|
domains, err := s.store.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, d := range domains {
|
||||||
|
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
||||||
|
if rua == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if IsHostedOnHostname(rua, s.hostname) {
|
||||||
|
add(rua)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosted := HostedReportAddress(s.hostname, d.Name)
|
||||||
|
if strings.EqualFold(rua, hosted) {
|
||||||
|
add(rua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recipientDomains(addrs []string) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
for _, addr := range addrs {
|
||||||
|
d := dnscheck.EmailDomain(addr)
|
||||||
|
if d == "" || seen[d] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d] = true
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostedSuggestion returns the address the panel should suggest for a domain.
|
||||||
|
func (s *Service) HostedSuggestion(domain string) string {
|
||||||
|
return HostedReportAddress(s.hostname, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHostedSuggestion is the settings-level hosted address.
|
||||||
|
func (s *Service) DefaultHostedSuggestion() string {
|
||||||
|
return DefaultHostedReportAddress(s.hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
||||||
|
func (s *Service) ValidateHostedAddress(addr string) error {
|
||||||
|
if !IsHostedOnHostname(addr, s.hostname) {
|
||||||
|
return fmt.Errorf("hosted report address must be on %s", s.hostname)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ package dnscheck
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -60,6 +61,7 @@ type Domain struct {
|
|||||||
DKIM Result
|
DKIM Result
|
||||||
SPF Result
|
SPF Result
|
||||||
DMARC Result
|
DMARC Result
|
||||||
|
DMARCReportAuth Result // zero when external rua= is not used
|
||||||
Overall health.Status
|
Overall health.Status
|
||||||
CheckedAt time.Time
|
CheckedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ type Query struct {
|
|||||||
ExpectedDKIM string
|
ExpectedDKIM string
|
||||||
Hostname string
|
Hostname string
|
||||||
ServerIPs []string
|
ServerIPs []string
|
||||||
|
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||||
@@ -96,6 +99,7 @@ type Checker struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
servers map[string]cached[Server]
|
servers map[string]cached[Server]
|
||||||
domains map[string]cached[Domain]
|
domains map[string]cached[Domain]
|
||||||
|
inbound map[string]cached[Result]
|
||||||
}
|
}
|
||||||
|
|
||||||
type cached[T any] struct {
|
type cached[T any] struct {
|
||||||
@@ -118,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
|||||||
domainTTL: domTTL,
|
domainTTL: domTTL,
|
||||||
servers: make(map[string]cached[Server]),
|
servers: make(map[string]cached[Server]),
|
||||||
domains: make(map[string]cached[Domain]),
|
domains: make(map[string]cached[Domain]),
|
||||||
|
inbound: make(map[string]cached[Result]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,20 +175,95 @@ func (c *Checker) Domain(q Query, force bool) Domain {
|
|||||||
func (c *Checker) Forget(domainName string) {
|
func (c *Checker) Forget(domainName string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.domains, domainName)
|
delete(c.domains, domainName)
|
||||||
|
delete(c.inbound, domainName)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundMX reports whether any MX for name points at this server's hostname
|
||||||
|
// (the inbound-relay check). Other MX values are the domain's own primaries
|
||||||
|
// and are not an error. force skips the cache.
|
||||||
|
func (c *Checker) InboundMX(name, hostname string, force bool) Result {
|
||||||
|
key := name + "\x00" + hostname
|
||||||
|
if !force {
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, ok := c.inbound[key]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok && time.Now().Before(entry.expires) {
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
result := c.checkInboundMX(ctx, name, hostname)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
|
||||||
|
mxs, err := c.resolver.LookupMX(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookupFailed("the MX record", err)
|
||||||
|
}
|
||||||
|
if len(mxs) == 0 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := normalizeName(hostname)
|
||||||
|
var records []string
|
||||||
|
matched := false
|
||||||
|
for _, mx := range mxs {
|
||||||
|
host := normalizeName(mx.Host)
|
||||||
|
records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host))
|
||||||
|
if host == want {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusOK,
|
||||||
|
Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkDomain runs the three record checks concurrently: they are independent,
|
// checkDomain runs the three record checks concurrently: they are independent,
|
||||||
// and in series three timeouts would stack up into a page that looks hung.
|
// and in series three timeouts would stack up into a page that looks hung.
|
||||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||||
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
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.SPF = c.checkSPF(ctx, q) }()
|
||||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
|
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
hub := EmailDomain(q.DMARCReportEmail)
|
||||||
|
if hub != "" && !strings.EqualFold(hub, q.Name) {
|
||||||
|
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
|
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
txt["_dmarc.example.com"] = c.record
|
txt["_dmarc.example.com"] = c.record
|
||||||
}
|
}
|
||||||
f := &fakeResolver{txt: txt}
|
f := &fakeResolver{txt: txt}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if got.Status != c.want {
|
if got.Status != c.want {
|
||||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
|
|
||||||
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
||||||
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if !strings.Contains(got.Detail, "monitoring only") {
|
if !strings.Contains(got.Detail, "monitoring only") {
|
||||||
t.Errorf("p=none is not explained: %s", got.Detail)
|
t.Errorf("p=none is not explained: %s", got.Detail)
|
||||||
}
|
}
|
||||||
@@ -357,3 +357,54 @@ func TestForgetDropsTheCachedDomain(t *testing.T) {
|
|||||||
t.Error("Forget did not drop the cached result")
|
t.Error("Forget did not drop the cached result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportAuth(t *testing.T) {
|
||||||
|
f := &fakeResolver{txt: map[string][]string{"_report._dmarc.hub.example": {"v=DMARC1;"}}}
|
||||||
|
got := newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusOK {
|
||||||
|
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = &fakeResolver{}
|
||||||
|
got = newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusWarn {
|
||||||
|
t.Fatalf("missing = %q, want warn", got.Status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Detail, ReportAuthExample()) {
|
||||||
|
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMXPointsAtServer(t *testing.T) {
|
||||||
|
f := &fakeResolver{
|
||||||
|
mx: map[string][]*net.MX{
|
||||||
|
"lists.example.com": {
|
||||||
|
{Host: "mail.primary.example.net.", Pref: 10},
|
||||||
|
{Host: "mail.example.org.", Pref: 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false)
|
||||||
|
if got.Status != health.StatusOK {
|
||||||
|
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMXMissingThisServer(t *testing.T) {
|
||||||
|
f := &fakeResolver{
|
||||||
|
mx: map[string][]*net.MX{
|
||||||
|
"backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false)
|
||||||
|
if got.Status != health.StatusError {
|
||||||
|
t.Fatalf("status = %q, want error", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMXAbsent(t *testing.T) {
|
||||||
|
got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false)
|
||||||
|
if got.Status != health.StatusError {
|
||||||
|
t.Fatalf("status = %q, want error", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dnscheck
|
package dnscheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
// helper: it is published at the domain itself.)
|
// helper: it is published at the domain itself.)
|
||||||
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
||||||
|
|
||||||
|
// ReportAuthRecordName is where a report-receiving domain authorises external
|
||||||
|
// DMARC aggregate destinations (RFC 7489 §7.1).
|
||||||
|
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
|
||||||
|
|
||||||
|
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
|
||||||
|
func ReportAuthExample() string { return "v=DMARC1;" }
|
||||||
|
|
||||||
// SPFExample is the SPF record this server expects for a sending domain: the
|
// 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
|
// 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
|
// is authorised. When the server's own addresses are not known (its hostname
|
||||||
@@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string {
|
|||||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DMARCExample is the least a domain should publish: monitoring only, with an
|
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
|
||||||
// address the aggregate reports go to. p=none is deliberate — it changes
|
// override wins, then the administrator profile, then policy-only (empty).
|
||||||
// nothing about delivery, so it is safe to publish before the reports have
|
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
|
||||||
// shown that DKIM and SPF pass everywhere.
|
if domainRua.Valid {
|
||||||
func DMARCExample(domainName string) string {
|
return domainRua.String
|
||||||
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
|
}
|
||||||
|
return profileEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||||
|
func EmailDomain(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at < 0 || at == len(addr)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCExample is the DMARC TXT record this server suggests for a sending
|
||||||
|
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
|
||||||
|
// included only when reportEmail is set; SelfPost is send-only and most
|
||||||
|
// operators have no inbox on the sending domain itself.
|
||||||
|
func DMARCExample(reportEmail string) string {
|
||||||
|
base := "v=DMARC1; p=none"
|
||||||
|
if reportEmail == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "; rua=mailto:" + reportEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalReportAuth reports whether the hub domain must publish a
|
||||||
|
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
|
||||||
|
// sendingDomain.
|
||||||
|
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
|
||||||
|
hub := EmailDomain(reportEmail)
|
||||||
|
if hub == "" || strings.EqualFold(hub, sendingDomain) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return ReportAuthRecordName(hub), ReportAuthExample(), true
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package dnscheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,8 +19,6 @@ func TestSPFExample(t *testing.T) {
|
|||||||
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
{"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"},
|
{"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"},
|
"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"},
|
{"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"},
|
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
||||||
}
|
}
|
||||||
@@ -32,6 +31,41 @@ func TestSPFExample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDMARCExample(t *testing.T) {
|
||||||
|
if got := DMARCExample(""); got != "v=DMARC1; p=none" {
|
||||||
|
t.Errorf("empty = %q", got)
|
||||||
|
}
|
||||||
|
want := "v=DMARC1; p=none; rua=mailto:reports@hub.example"
|
||||||
|
if got := DMARCExample("reports@hub.example"); got != want {
|
||||||
|
t.Errorf("with rua = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDMARCRua(t *testing.T) {
|
||||||
|
inherit := sql.NullString{}
|
||||||
|
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
|
||||||
|
t.Errorf("inherit profile = %q", got)
|
||||||
|
}
|
||||||
|
none := sql.NullString{Valid: true}
|
||||||
|
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
|
||||||
|
t.Errorf("explicit none = %q", got)
|
||||||
|
}
|
||||||
|
custom := sql.NullString{Valid: true, String: "x@y.com"}
|
||||||
|
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
|
||||||
|
t.Errorf("custom = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalReportAuth(t *testing.T) {
|
||||||
|
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||||
|
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
||||||
|
t.Fatalf("external = (%q, %q, %v)", name, value, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := ExternalReportAuth("shop.com", "dmarc@shop.com"); ok {
|
||||||
|
t.Fatal("same domain should not need external auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The record the panel shows and the one a failed check suggests must be the
|
// 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.
|
// same string, or the operator is told two different things on one page.
|
||||||
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||||
@@ -50,11 +84,11 @@ func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
|||||||
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
dmarc := c.checkDMARC(context.Background(), "example.com")
|
dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if dmarc.Status != health.StatusWarn {
|
if dmarc.Status != health.StatusWarn {
|
||||||
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
||||||
}
|
}
|
||||||
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
|
if want := DMARCExample(""); !strings.Contains(dmarc.Detail, want) {
|
||||||
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
|||||||
|
|
||||||
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
||||||
// required for delivery, so its absence is advice (warn), not a fault.
|
// required for delivery, so its absence is advice (warn), not a fault.
|
||||||
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
|
||||||
name := DMARCRecordName(domainName)
|
name := DMARCRecordName(q.Name)
|
||||||
txt, found, err := c.lookupTXT(ctx, name)
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return lookupFailed("the DMARC record", err)
|
return lookupFailed("the DMARC record", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
example := DMARCExample(q.DMARCReportEmail)
|
||||||
|
|
||||||
var records []string
|
var records []string
|
||||||
for _, rec := range txt {
|
for _, rec := range txt {
|
||||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||||
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found || len(records) == 0 {
|
if !found || len(records) == 0 {
|
||||||
return Result{
|
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
|
||||||
Status: health.StatusWarn,
|
if q.DMARCReportEmail == "" {
|
||||||
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)),
|
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
|
||||||
}
|
}
|
||||||
|
return Result{Status: health.StatusWarn, Detail: detail}
|
||||||
}
|
}
|
||||||
if len(records) > 1 {
|
if len(records) > 1 {
|
||||||
return Result{
|
return Result{
|
||||||
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
|
||||||
|
// aggregate-report destinations. Missing authorisation does not affect outbound
|
||||||
|
// delivery, only whether reports reach the rua= mailbox.
|
||||||
|
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
name := ReportAuthRecordName(hubDomain)
|
||||||
|
expected := ReportAuthExample()
|
||||||
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return lookupFailed("the DMARC report-authorisation 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 report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(records) > 1 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusOK,
|
||||||
|
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
|
||||||
|
// reports. It is used on the settings page for the administrator profile.
|
||||||
|
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
if hubDomain == "" {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return c.checkReportAuth(ctx, hubDomain)
|
||||||
|
}
|
||||||
|
|
||||||
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
||||||
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
||||||
// line breaks into the base64, none of which are part of the key.
|
// line breaks into the base64, none of which are part of the key.
|
||||||
|
|||||||
+3
-34
@@ -13,7 +13,8 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
|
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
|
||||||
@@ -91,37 +92,5 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
|
|||||||
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
|
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
|
||||||
// (security.md).
|
// (security.md).
|
||||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
dir := filepath.Dir(path)
|
return atomicfile.Write(path, data, perm)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
+25
-16
@@ -5,10 +5,13 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/configsafe"
|
||||||
|
"github.com/mixeme/selfpost/internal/supervisor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
|
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
|
||||||
@@ -167,6 +170,14 @@ func (o *OpenDKIM) Reload() error {
|
|||||||
return o.reload()
|
return o.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
|
||||||
|
// that cannot reach supervisord use this to verify file regeneration alone.
|
||||||
|
func (o *OpenDKIM) SetReloadHook(fn func() error) {
|
||||||
|
if fn != nil {
|
||||||
|
o.reload = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// renderTables builds the KeyTable and SigningTable byte contents for a domain
|
// renderTables builds the KeyTable and SigningTable byte contents for a domain
|
||||||
// set, sorted by name so the output is deterministic. Every domain is
|
// set, sorted by name so the output is deterministic. Every domain is
|
||||||
// re-checked for shell/config-injection safety before being written
|
// re-checked for shell/config-injection safety before being written
|
||||||
@@ -183,7 +194,7 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
|||||||
}
|
}
|
||||||
keyName := d.Name // one key per domain; the domain name is a fine handle
|
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.
|
// Absolute key path so OpenDKIM resolves it independently of its CWD.
|
||||||
keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private")
|
keyFile := path.Join(keysDir, d.Name, d.Selector+".private")
|
||||||
// KeyTable: <key-name> <domain>:<selector>:<key-path>
|
// KeyTable: <key-name> <domain>:<selector>:<key-path>
|
||||||
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
|
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
|
||||||
// SigningTable (refile): <address-pattern> <key-name>
|
// SigningTable (refile): <address-pattern> <key-name>
|
||||||
@@ -192,18 +203,23 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
|||||||
return []byte(kt.String()), []byte(st.String()), nil
|
return []byte(kt.String()), []byte(st.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
|
||||||
|
// value: whitespace and the newline that would end the line, and the colon and
|
||||||
|
// path separators that carry meaning inside a table entry.
|
||||||
|
const opendkimForbidden = " \t\r\n:/\\"
|
||||||
|
|
||||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
// 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
|
// 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
|
// 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
|
// against a validation gap ever letting whitespace, a newline or a field
|
||||||
// separator through into a config file (security.md).
|
// separator through into a config file (security.md).
|
||||||
func assertConfigSafe(domainName, selector string) error {
|
func assertConfigSafe(domainName, selector string) error {
|
||||||
for _, v := range []string{domainName, selector} {
|
for _, f := range []struct{ what, value string }{
|
||||||
if v == "" {
|
{"domain", domainName},
|
||||||
return fmt.Errorf("opendkim: empty domain or selector")
|
{"selector", selector},
|
||||||
}
|
} {
|
||||||
if strings.ContainsAny(v, " \t\r\n:/\\") {
|
if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
|
||||||
return fmt.Errorf("opendkim: unsafe character in %q", v)
|
return fmt.Errorf("opendkim: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -218,12 +234,5 @@ func assertConfigSafe(domainName, selector string) error {
|
|||||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||||
// and it never goes through a shell (security.md).
|
// and it never goes through a shell (security.md).
|
||||||
func reloadViaSupervisor() error {
|
func reloadViaSupervisor() error {
|
||||||
cmd := exec.Command("supervisorctl",
|
return supervisor.Signal("USR1", "opendkim")
|
||||||
"-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
|
|
||||||
}
|
}
|
||||||
@@ -145,16 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
// 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
|
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||||
// needed.
|
// Domain limits do not use an IP allowlist.
|
||||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
rl.Scope = store.RateLimitScopeDomain
|
||||||
Scope: store.RateLimitScopeDomain,
|
rl.RefID = domainID
|
||||||
RefID: domainID,
|
rl.AllowedIPs = nil
|
||||||
AllowedIPs: ips,
|
return s.store.SetRateLimit(rl)
|
||||||
MaxMessages: maxMessages,
|
|
||||||
WindowSeconds: windowSeconds,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
||||||
|
|||||||
+112
-2
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
@@ -23,15 +24,29 @@ type DomainExport struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
DKIMSelector string `json:"dkim_selector"`
|
DKIMSelector string `json:"dkim_selector"`
|
||||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||||
|
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||||
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimitExport is the transferable level-2 limit for a domain or application.
|
||||||
|
type RateLimitExport struct {
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
MaxMessages int `json:"max_messages,omitempty"`
|
||||||
|
WindowSeconds int `json:"window_seconds,omitempty"`
|
||||||
|
AutoMultiplier float64 `json:"auto_multiplier,omitempty"`
|
||||||
|
AllowedIPs []string `json:"allowed_ips,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// AppExport is one application within a DomainExport.
|
// AppExport is one application within a DomainExport.
|
||||||
type AppExport struct {
|
type AppExport struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
AddressMode string `json:"address_mode"`
|
AddressMode string `json:"address_mode"`
|
||||||
Addresses []string `json:"addresses,omitempty"` // list mode only
|
Addresses []string `json:"addresses,omitempty"` // list mode only
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
AuthIPRestrict bool `json:"auth_ip_restrict,omitempty"`
|
||||||
|
AuthAllowedIPs []string `json:"auth_allowed_ips,omitempty"`
|
||||||
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export builds the transferable representation of a domain: its DKIM key, its
|
// Export builds the transferable representation of a domain: its DKIM key, its
|
||||||
@@ -59,17 +74,38 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
|||||||
DKIMPrivateKey: string(pem),
|
DKIMPrivateKey: string(pem),
|
||||||
Applications: make([]AppExport, 0, len(apps)),
|
Applications: make([]AppExport, 0, len(apps)),
|
||||||
}
|
}
|
||||||
|
if d.DMARCRua.Valid {
|
||||||
|
s := d.DMARCRua.String
|
||||||
|
exp.DMARCRua = &s
|
||||||
|
}
|
||||||
|
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
|
||||||
|
if err != nil {
|
||||||
|
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", d.Name, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
exp.RateLimit = exportRateLimit(rl)
|
||||||
|
}
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
password, err := s.apps.Secret(a.Login)
|
password, err := s.apps.Secret(a.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
|
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
|
||||||
}
|
}
|
||||||
exp.Applications = append(exp.Applications, AppExport{
|
appExp := AppExport{
|
||||||
Login: a.Login,
|
Login: a.Login,
|
||||||
AddressMode: a.AddressMode,
|
AddressMode: a.AddressMode,
|
||||||
Addresses: a.Addresses,
|
Addresses: a.Addresses,
|
||||||
Password: password,
|
Password: password,
|
||||||
})
|
AuthIPRestrict: a.AuthIPRestrict,
|
||||||
|
AuthAllowedIPs: a.AuthAllowedIPs,
|
||||||
|
}
|
||||||
|
appRL, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
appExp.RateLimit = exportRateLimit(appRL)
|
||||||
|
}
|
||||||
|
exp.Applications = append(exp.Applications, appExp)
|
||||||
}
|
}
|
||||||
return exp, nil
|
return exp, nil
|
||||||
}
|
}
|
||||||
@@ -110,11 +146,52 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
return store.Domain{}, err
|
return store.Domain{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exp.DMARCRua != nil {
|
||||||
|
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, err
|
||||||
|
}
|
||||||
|
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
||||||
|
}
|
||||||
|
if exp.RateLimit != nil {
|
||||||
|
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, a := range exp.Applications {
|
for _, a := range exp.Applications {
|
||||||
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
||||||
}
|
}
|
||||||
|
restrict, ips := a.AuthIPRestrict, a.AuthAllowedIPs
|
||||||
|
// Legacy exports stored client IPs on the rate-limit row as trusted IPs.
|
||||||
|
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
|
||||||
|
restrict = true
|
||||||
|
ips = a.RateLimit.AllowedIPs
|
||||||
|
}
|
||||||
|
needAuthIPs := restrict || len(ips) > 0
|
||||||
|
needRateLimit := a.RateLimit != nil
|
||||||
|
if needAuthIPs || needRateLimit {
|
||||||
|
app, err := s.store.GetApplicationByLogin(a.Login)
|
||||||
|
if err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
if needAuthIPs {
|
||||||
|
if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import auth IPs for %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needRateLimit {
|
||||||
|
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := s.apps.Resync(); err != nil {
|
if err := s.apps.Resync(); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
@@ -131,3 +208,36 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
func (s *Service) importRollback(id int64) {
|
func (s *Service) importRollback(id int64) {
|
||||||
_ = s.Delete(id)
|
_ = s.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportRateLimit(rl store.RateLimit) *RateLimitExport {
|
||||||
|
mode := rl.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = store.RateLimitModeManual
|
||||||
|
}
|
||||||
|
exp := &RateLimitExport{
|
||||||
|
Mode: mode,
|
||||||
|
MaxMessages: rl.MaxMessages,
|
||||||
|
WindowSeconds: rl.WindowSeconds,
|
||||||
|
AutoMultiplier: rl.AutoMultiplier,
|
||||||
|
}
|
||||||
|
return exp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport) error {
|
||||||
|
mode := exp.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = store.RateLimitModeManual
|
||||||
|
}
|
||||||
|
rl := store.RateLimit{
|
||||||
|
Scope: scope,
|
||||||
|
RefID: refID,
|
||||||
|
Mode: mode,
|
||||||
|
MaxMessages: exp.MaxMessages,
|
||||||
|
WindowSeconds: exp.WindowSeconds,
|
||||||
|
AutoMultiplier: exp.AutoMultiplier,
|
||||||
|
}
|
||||||
|
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.store.SetRateLimit(rl)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,6 +73,9 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||||
t.Fatalf("add alerts: %v", err)
|
t.Fatalf("add alerts: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
|
||||||
|
t.Fatalf("set dmarc rua: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
exp, err := src.Export(d.ID)
|
exp, err := src.Export(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +111,12 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||||
t.Errorf("imported domain = %+v", got)
|
t.Errorf("imported domain = %+v", got)
|
||||||
}
|
}
|
||||||
|
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
|
||||||
|
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||||
|
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
||||||
|
}
|
||||||
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
||||||
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+100
-35
@@ -62,10 +62,14 @@ type CPU struct {
|
|||||||
// nothing until it is true.
|
// nothing until it is true.
|
||||||
Measured bool
|
Measured bool
|
||||||
BusyPct float64
|
BusyPct float64
|
||||||
|
// Cores and Threads are physical cores and logical CPUs (SMT). Both come
|
||||||
|
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
|
||||||
|
// the cpuN lines in /proc/stat and Cores matches Threads.
|
||||||
Cores int
|
Cores int
|
||||||
|
Threads int
|
||||||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
// 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
|
// could be read. Unlike BusyPct it needs no previous sample. It is kept
|
||||||
// there on the very first page load.
|
// for the sampler but is no longer shown on the Status card.
|
||||||
Load [3]float64
|
Load [3]float64
|
||||||
HasLoad bool
|
HasLoad bool
|
||||||
Status Status
|
Status Status
|
||||||
@@ -176,7 +180,14 @@ type netCounters struct {
|
|||||||
func (m *MachineSampler) Sample() Machine {
|
func (m *MachineSampler) Sample() Machine {
|
||||||
root := m.root()
|
root := m.root()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
cpuNow, logical, cpuErr := readCPUTimes(root)
|
||||||
|
cores, threads := readCPUTopology(root)
|
||||||
|
if threads == 0 {
|
||||||
|
threads = logical
|
||||||
|
}
|
||||||
|
if cores == 0 {
|
||||||
|
cores = threads
|
||||||
|
}
|
||||||
netNow, netErr := readNetDev(root)
|
netNow, netErr := readNetDev(root)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine {
|
|||||||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||||||
|
|
||||||
mach := Machine{
|
mach := Machine{
|
||||||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
|
||||||
Memory: readMemory(root),
|
Memory: readMemory(root),
|
||||||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||||||
}
|
}
|
||||||
@@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine {
|
|||||||
return mach
|
return mach
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuUsage grades the processor over the window. The load average is reported
|
// cpuUsage grades the processor over the window. Detail carries only the
|
||||||
// alongside it because the two answer different questions — how busy the
|
// core and thread counts — load average and busy prose stay out of the Status
|
||||||
// processor was, and how many tasks were waiting for it — and a machine can
|
// card (see roadmap panel-docs for operator-facing explanation later).
|
||||||
// look idle while work queues up behind a slow disk.
|
func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
|
||||||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
c := CPU{Cores: cores, Threads: threads}
|
||||||
c := CPU{Cores: cores}
|
|
||||||
if !isZeroLoad(load) {
|
if !isZeroLoad(load) {
|
||||||
c.Load, c.HasLoad = load, true
|
c.Load, c.HasLoad = load, true
|
||||||
}
|
}
|
||||||
@@ -223,9 +233,9 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
|||||||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
c.Detail = c.contextText()
|
||||||
if !fresh || cur.total <= prev.total {
|
if !fresh || cur.total <= prev.total {
|
||||||
c.Status = StatusUnknown
|
c.Status = StatusUnknown
|
||||||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
|||||||
|
|
||||||
if c.BusyPct >= cpuWarnPct {
|
if c.BusyPct >= cpuWarnPct {
|
||||||
c.Status = StatusWarn
|
c.Status = StatusWarn
|
||||||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
|
||||||
} else {
|
} else {
|
||||||
c.Status = StatusOK
|
c.Status = StatusOK
|
||||||
c.Detail = c.contextText()
|
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextText is the CPU's supporting figures: what the percentage is a
|
// contextText is the CPU detail column: physical cores and logical threads.
|
||||||
// percentage of, and how deep the run queue is.
|
|
||||||
func (c CPU) contextText() string {
|
func (c CPU) contextText() string {
|
||||||
var parts []string
|
switch {
|
||||||
if c.Cores > 0 {
|
case c.Cores > 0 && c.Threads > 0:
|
||||||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
|
||||||
|
case c.Threads > 0:
|
||||||
|
return fmt.Sprintf("%d threads", c.Threads)
|
||||||
|
case c.Cores > 0:
|
||||||
|
return fmt.Sprintf("%d cores", c.Cores)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
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
|
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||||||
@@ -296,8 +305,7 @@ func readMemory(root string) Memory {
|
|||||||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||||||
}
|
}
|
||||||
|
|
||||||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
|
||||||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
|
||||||
if m.SwapTotalBytes > 0 {
|
if m.SwapTotalBytes > 0 {
|
||||||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||||||
}
|
}
|
||||||
@@ -418,6 +426,74 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
|
|||||||
return times, cores, nil
|
return times, cores, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCPUTopology returns physical core and logical thread counts from
|
||||||
|
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
|
||||||
|
// back to the cpuN count from /proc/stat.
|
||||||
|
func readCPUTopology(root string) (cores, threads int) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
type coreKey struct{ phys, core int }
|
||||||
|
seen := map[coreKey]bool{}
|
||||||
|
var (
|
||||||
|
inCPU bool
|
||||||
|
phys = -1
|
||||||
|
core = -1
|
||||||
|
idx int
|
||||||
|
)
|
||||||
|
flush := func() {
|
||||||
|
if !inCPU {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, c := phys, core
|
||||||
|
if p < 0 {
|
||||||
|
p = idx
|
||||||
|
}
|
||||||
|
if c < 0 {
|
||||||
|
c = idx
|
||||||
|
}
|
||||||
|
seen[coreKey{p, c}] = true
|
||||||
|
inCPU, phys, core = false, -1, -1
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
switch key {
|
||||||
|
case "processor":
|
||||||
|
flush()
|
||||||
|
inCPU = true
|
||||||
|
threads++
|
||||||
|
idx = threads - 1
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
idx = n
|
||||||
|
}
|
||||||
|
case "physical id":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
phys = n
|
||||||
|
}
|
||||||
|
case "core id":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
core = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if threads == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return len(seen), threads
|
||||||
|
}
|
||||||
|
|
||||||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
// 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
|
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||||||
// than an error.
|
// than an error.
|
||||||
@@ -544,14 +620,3 @@ func humanRate(perSec float64) string {
|
|||||||
}
|
}
|
||||||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
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, " ")
|
|
||||||
}
|
|
||||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user