Compare commits
77 Commits
15baa1e5d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,11 +1,9 @@
|
|||||||
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 (spec 10.1).
|
||||||
# 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 the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||||
@@ -13,14 +11,18 @@ name: release
|
|||||||
# tag on its own native runner; a merge job then combines them into the one
|
# tag on its own native runner; a merge job then combines them into the one
|
||||||
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
|
||||||
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
# push-by-digest then test) is deliberate: it means the bytes that get tagged
|
||||||
# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering
|
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
|
||||||
# in the registry as a side effect (harmless — the version tag's immutability,
|
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
|
||||||
# spec 10.1, is about that tag, not these).
|
# so operators see a single version tag (spec 10.1).
|
||||||
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
|
||||||
|
|||||||
+444
@@ -7,6 +7,412 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## [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](docs/plans/queue-retries.md).
|
||||||
|
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.
|
||||||
|
|
||||||
|
- 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 merge job had 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.
|
||||||
|
|
||||||
|
### 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, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest,
|
||||||
|
then removes the per-arch tags from GHCR via the GitHub Packages API so
|
||||||
|
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what
|
||||||
|
`deploy/docker-compose.yml` pins). 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](docs/plans/dmarc-reports.md) 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.
|
||||||
|
|
||||||
|
## [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
|
- panel: **domain-admin role** — global administrators manage panel users and
|
||||||
assign domains; domain administrators see only their domains (applications,
|
assign domains; domain administrators see only their domains (applications,
|
||||||
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
|
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
|
||||||
@@ -15,11 +421,49 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
administrator into a global user; sessions and full backup restore carry users
|
administrator into a global user; sessions and full backup restore carry users
|
||||||
and domain bindings.
|
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
|
### 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
|
- panel: `/` redirects domain administrators to `/domains`; global users still
|
||||||
land on `/status`. Navigation hides global-only sections for domain
|
land on `/status`. Navigation hides global-only sections for domain
|
||||||
administrators.
|
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
|
## [1.1.0] - 2026-08-10
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,32 @@ Corresponding Source for the published releases is at:
|
|||||||
|
|
||||||
If you modify SelfPost and let users interact with it over a network,
|
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
|
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||||
your modified version. Update the Source link in the panel footer
|
your modified version. Update SourceURL in internal/legal/legal.go
|
||||||
(internal/web/templates/layout.html) so it points at your sources.
|
so the panel footer points at your sources.
|
||||||
|
|
||||||
Third-party software included in this distribution
|
Third-party software included in this distribution
|
||||||
----------------------------------------------------
|
----------------------------------------------------
|
||||||
|
|
||||||
htmx 2.0.4 (internal/web/static/htmx.min.js)
|
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||||
Copyright (c) Big Sky Software
|
Copyright (c) Big Sky Software
|
||||||
Licence: Zero-Clause BSD (0BSD)
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
https://github.com/bigskysoftware/htmx
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
IBM Plex Sans / IBM Plex Mono (wordmarks in SVG logos only)
|
IBM Plex Sans / IBM Plex Mono
|
||||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
Licence: SIL Open Font License 1.1
|
Licence: SIL Open Font License 1.1
|
||||||
The shipped SVGs use outlined paths derived from these fonts; the
|
Full text: internal/web/view/static/OFL.txt
|
||||||
font files themselves are not distributed.
|
(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
|
Go module dependencies are listed with their licences in
|
||||||
docs/development.md § External libraries. Debian packages in the
|
docs/development.md § External libraries. Debian packages in the
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -76,14 +76,15 @@ 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.
|
||||||
|
|
||||||
## 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;
|
||||||
@@ -95,7 +96,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.1.0
|
ghcr.io/mixeme/selfpost:1.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -129,136 +130,17 @@ 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** and **587**; Postfix listens on 587
|
||||||
|
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
|
||||||
```sh
|
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
|
||||||
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
variables](docs/guide.md#environment-variables).
|
||||||
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
|
|
||||||
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
|
|
||||||
your provider and the certificate your proxy will obtain.
|
|
||||||
|
|
||||||
### 2. Reverse proxy and TLS
|
|
||||||
|
|
||||||
Pick one proxy. In every case the proxy terminates HTTPS for the panel; the
|
|
||||||
same certificate must end up under `./certs` as `fullchain.pem` and
|
|
||||||
`privkey.pem` so Postfix can serve it on 465 (and 587 if enabled). The proxy
|
|
||||||
must **pass the original `Host` header** — details and rationale:
|
|
||||||
[Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
|
||||||
|
|
||||||
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
|
|
||||||
`proxy_http` enabled. Copy
|
|
||||||
[deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) into your
|
|
||||||
vhost directory, replace `mail.example.com` with your hostname, enable the site,
|
|
||||||
then issue a certificate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo certbot --apache -d mail.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Point `./certs` at the PEM files certbot wrote (symlink is fine):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ln -s /etc/letsencrypt/live/mail.example.com certs
|
|
||||||
```
|
|
||||||
|
|
||||||
**nginx (containerised).** From the `deploy/` directory, merge the nginx
|
|
||||||
fragment and issue the first certificate before nginx can serve HTTPS:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
|
|
||||||
run --rm certbot certonly --webroot -w /var/www/certbot \
|
|
||||||
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
|
|
||||||
|
|
||||||
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit [deploy/nginx/nginx.conf.example](deploy/nginx/nginx.conf.example) and
|
|
||||||
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
|
|
||||||
both nginx and SelfPost.
|
|
||||||
|
|
||||||
**Caddy (containerised, automatic ACME).** Edit
|
|
||||||
[deploy/caddy/Caddyfile](deploy/caddy/Caddyfile) and the `<hostname>` placeholders
|
|
||||||
in [deploy/caddy/docker-compose.caddy.yml](deploy/caddy/docker-compose.caddy.yml),
|
|
||||||
then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify Caddy's on-disk cert path for your version before relying on the
|
|
||||||
default mount — see the comment at the top of the Caddy compose fragment.
|
|
||||||
|
|
||||||
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
|
|
||||||
[deploy/traefik/docker-compose.traefik.yml](deploy/traefik/docker-compose.traefik.yml),
|
|
||||||
start the stack, then extract PEM files for Postfix whenever Traefik issues or
|
|
||||||
renews a certificate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
|
|
||||||
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
|
|
||||||
```
|
|
||||||
|
|
||||||
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
|
||||||
|
|
||||||
### 3. Start SelfPost
|
|
||||||
|
|
||||||
If you used Apache on the host (step 2, first option), start only the base
|
|
||||||
compose file from your `selfpost/` directory:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The nginx/Caddy/Traefik fragments from step 2 already include `docker compose up
|
|
||||||
-d` — skip this if you ran one of those.
|
|
||||||
|
|
||||||
**Get the setup URL** — open it in a browser to create the admin account
|
|
||||||
([first boot](#quick-start)):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose logs selfpost 2>&1 | grep -m1 'http'
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cat ./data/setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
The file is deleted as soon as setup completes. If logs are shipped to a
|
|
||||||
central aggregator, prefer `cat ./data/setup-token` so the bearer token does
|
|
||||||
not enter the log pipeline.
|
|
||||||
|
|
||||||
### 4. DNS and sending
|
|
||||||
|
|
||||||
Before sending real mail:
|
|
||||||
|
|
||||||
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
|
|
||||||
page → *Re-check*).
|
|
||||||
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
|
||||||
same time ([DNS setup](docs/guide.md#dns-setup)).
|
|
||||||
3. Warm up a new IP gradually ([IP warmup](docs/guide.md#ip-warmup)).
|
|
||||||
|
|
||||||
### Ports and upgrades
|
|
||||||
|
|
||||||
The compose file maps **465** (always) and **587** (when
|
|
||||||
`SUBMISSION_ENABLE=true`). Bump the pinned image tag deliberately when
|
|
||||||
upgrading — never use `:latest` ([why](docs/guide.md#fixed-image-tag)).
|
|
||||||
|
|
||||||
Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see
|
|
||||||
[Environment variables](docs/guide.md#environment-variables).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+18
-14
@@ -87,21 +87,25 @@ 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
|
||||||
|
|
||||||
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
# serves the same text at /license from an embedded copy.
|
# serves the same text at /license from an embedded copy. OFL.txt is the SIL
|
||||||
COPY LICENSE NOTICE /usr/share/doc/selfpost/
|
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
|
||||||
|
# embedded in the panel binary (NOTICE).
|
||||||
|
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
|
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
|
||||||
|
|
||||||
COPY build/opendkim.conf /etc/opendkim.conf
|
# File modes are pinned here so a build context copied from a checkout without
|
||||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
# that logrotate would silently ignore — see docs/development.md § Building
|
||||||
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
# binaries and the image.
|
||||||
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||||
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
COPY build/crashexit.py /usr/local/bin/crashexit.py
|
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||||
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \
|
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||||
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
|
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||||
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
|
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)
|
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||||
|
|||||||
+40
-1
@@ -14,10 +14,49 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ 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 (spec 9, 10: daily, 7-14 files kept
|
||||||
; in the image). Runs as root so logrotate can read/rotate the log; never exits
|
; in the image). Runs as root so logrotate can read/rotate the log. Exits
|
||||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
; non-zero when 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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ var documentedPublic = []string{
|
|||||||
"TRUSTED_PROXY_CIDR",
|
"TRUSTED_PROXY_CIDR",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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",
|
||||||
|
|||||||
+71
-21
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,31 +16,80 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/web"
|
"github.com/mixeme/selfpost/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mailStack is the panel's domain and application services plus the on-disk
|
||||||
|
// mail-path adapters they write through.
|
||||||
|
type mailStack struct {
|
||||||
|
Domains *domain.Service
|
||||||
|
Apps *app.Service
|
||||||
|
pf *postfix.Postfix
|
||||||
|
odk *domain.OpenDKIM
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return web.New(st, ms.Domains, ms.Apps, web.Config{
|
||||||
|
Hostname: cfg.hostname,
|
||||||
|
CookieSecure: cfg.cookieSecure,
|
||||||
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
|
MailLogPath: cfg.mailLog,
|
||||||
|
DataDir: cfg.dataDir,
|
||||||
|
DBPath: cfg.dbPath,
|
||||||
|
Version: buildinfo.Version,
|
||||||
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
|
JournalSocket: cfg.journalSocket,
|
||||||
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
|
}, cfg.setupTokenPath)
|
||||||
|
}
|
||||||
|
|
||||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||||
// the database handle shared by all roles: setup, login and the authenticated
|
// the database handle shared by all roles: setup, login and the authenticated
|
||||||
// panel surface (security.md).
|
// panel surface (security.md).
|
||||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
srvApp, err := newPanel(cfg, st)
|
||||||
// service delegates to them when a domain (and its applications) is deleted.
|
|
||||||
pf := postfix.New(cfg.postfixDir)
|
|
||||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
|
||||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
|
||||||
|
|
||||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
|
||||||
Hostname: cfg.hostname,
|
|
||||||
CookieSecure: cfg.cookieSecure,
|
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
|
||||||
MailLogPath: cfg.mailLog,
|
|
||||||
DataDir: cfg.dataDir,
|
|
||||||
DBPath: cfg.dbPath,
|
|
||||||
Version: buildinfo.Version,
|
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
|
||||||
OpenDKIMSocket: cfg.opendkimSocket,
|
|
||||||
JournalSocket: cfg.journalSocket,
|
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
|
||||||
DNSResolvers: cfg.dnsResolvers,
|
|
||||||
}, cfg.setupTokenPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-12
@@ -4,7 +4,6 @@
|
|||||||
// the rate-limit checks.
|
// the rate-limit checks.
|
||||||
//
|
//
|
||||||
// Copyright (C) 2026 Mikhail Yenuchenko
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -53,16 +52,18 @@ type config struct {
|
|||||||
mailLog string
|
mailLog string
|
||||||
retentionDays int
|
retentionDays int
|
||||||
|
|
||||||
dataDir string
|
dataDir string
|
||||||
dbPath string
|
dbPath string
|
||||||
manifestPath string
|
manifestPath string
|
||||||
setupTokenPath string
|
setupTokenPath string
|
||||||
hostname string
|
hostname string
|
||||||
cookieSecure bool
|
cookieSecure bool
|
||||||
submissionEnabled bool
|
submissionEnabled bool
|
||||||
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
|
||||||
@@ -117,6 +118,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"),
|
||||||
|
|
||||||
@@ -217,7 +222,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +236,13 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errc := make(chan error, 3)
|
errc := make(chan error, 3)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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 the /data bind mount 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
|
||||||
|
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 := seedPanelData(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), dataDir: target, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
dataDir := seedPanelData(t)
|
||||||
|
cfg := panelConfig(t, dataDir)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
live := seedPanelData(t)
|
||||||
|
var archive bytes.Buffer
|
||||||
|
if err := backup.Create(&archive, backup.Params{
|
||||||
|
DataDir: live,
|
||||||
|
DBPath: filepath.Join(live, "selfpost.db"),
|
||||||
|
Version: "9.9.9",
|
||||||
|
}); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPanelData builds the /data tree of a panel that has been in use: an
|
||||||
|
// administrator, a sending domain with an application and one logged message,
|
||||||
|
// and the daemon state the mail path needs (a DKIM key, the SASL database and
|
||||||
|
// Postfix's sender map).
|
||||||
|
func seedPanelData(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
|
||||||
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelConfig resolves the panel's own configuration for a data 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, dataDir string) config {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
|
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, dataDir string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg := panelConfig(t, dataDir)
|
||||||
|
|
||||||
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the panel refused to start on %s: %v", dataDir, 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 /data
|
||||||
|
// bind mount 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
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
// 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
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.1.0
|
image: ghcr.io/mixeme/selfpost:1.3.0
|
||||||
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}"
|
||||||
|
|||||||
+68
-25
@@ -81,11 +81,13 @@ One process, three roles:
|
|||||||
proxy only.
|
proxy only.
|
||||||
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 with trusted IPs
|
||||||
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
|
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||||
cannot each spend the same last slot; a reservation is released at
|
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||||
end-of-message, on ABORT, or after a 10-minute TTL.
|
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)).
|
||||||
@@ -152,25 +154,31 @@ 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 |
|
||||||
| `/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 + Postfix maps |
|
||||||
| `/account` | Admin username/password |
|
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||||
|
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||||
|
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
||||||
|
|
||||||
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
|
||||||
@@ -189,13 +197,16 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
|||||||
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/auth/session.go](../internal/web/auth/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/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -290,9 +301,13 @@ Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
|||||||
`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 — SQLite snapshot + tar of
|
||||||
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
||||||
restore. Stopped-container `tar` of `./data` is safe (see guide).
|
restore. On the first successful boot after restore, the panel runs one
|
||||||
|
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
|
||||||
|
SQLite and both daemons are reloaded, so drift between the extracted archive
|
||||||
|
and the database is healed before mail flows (same step as `POST /reload` on
|
||||||
|
demand). Stopped-container `tar` of `./data` is safe (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 →
|
||||||
@@ -314,5 +329,33 @@ 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).
|
||||||
|
- **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`).
|
||||||
|
|||||||
@@ -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 full backup of all persistent state — the database, every domain’s DKIM key and the application credentials. Restore into a container of the <strong>same SelfPost version</strong>, with the same data mount, before first start. TLS certificates and the mail queue are 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>
|
||||||
+76
-17
@@ -93,8 +93,9 @@ 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)). Copyright holder and
|
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||||||
third-party notices: [NOTICE](../NOTICE). New Go dependencies must be
|
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
|
||||||
permissive or GPL-family (see
|
`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`)
|
||||||
@@ -113,6 +114,7 @@ the tree are AGPL-3.0-compatible.
|
|||||||
| Asset | Version | Repository | License |
|
| Asset | Version | Repository | License |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
|
| `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`)
|
||||||
|
|
||||||
@@ -128,8 +130,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) and [NOTICE](../NOTICE) under
|
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
|
||||||
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -141,7 +145,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.1.0
|
make build VERSION=1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
@@ -162,8 +166,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,9 +191,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):**
|
||||||
|
|
||||||
@@ -195,8 +204,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
|
||||||
@@ -263,7 +306,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.
|
||||||
|
|
||||||
@@ -278,21 +325,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.
|
||||||
|
|
||||||
|
|||||||
+532
-238
@@ -3,55 +3,53 @@
|
|||||||
Detailed install, configuration, and day-to-day operations. For a short
|
Detailed install, configuration, and day-to-day operations. For a short
|
||||||
overview and quick start, see [README.md](../README.md).
|
overview and quick start, see [README.md](../README.md).
|
||||||
|
|
||||||
|
This guide has three parts: **[Installation](#installation)** (getting a
|
||||||
|
container running with a working reverse proxy and TLS), **[Instance
|
||||||
|
administration](#instance-administration)** (running and maintaining the
|
||||||
|
SelfPost server itself — status, backups, users, upgrades), and **[Domain
|
||||||
|
administration](#domain-administration)** (day-to-day work on the sending
|
||||||
|
domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
- [Installation](#installation)
|
||||||
- [Local trial](#local-trial)
|
- [Ports](#ports)
|
||||||
- [Environment variables](#environment-variables)
|
- [Local trial](#local-trial)
|
||||||
- [DNS setup](#dns-setup)
|
- [Initial setup](#initial-setup)
|
||||||
- [IP warmup](#ip-warmup)
|
- [Full deployment](#full-deployment)
|
||||||
- [Operations](#operations)
|
- [Fixed image tag](#fixed-image-tag)
|
||||||
- [Rate limiting](#rate-limiting)
|
- [Environment variables](#environment-variables)
|
||||||
- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain)
|
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
|
||||||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
- [Instance administration](#instance-administration)
|
||||||
- [Published ports](#published-ports)
|
- [Status](#status)
|
||||||
- [Fixed image tag](#fixed-image-tag)
|
- [Mail queue and System log](#mail-queue-and-system-log)
|
||||||
|
- [Settings](#settings)
|
||||||
|
- [Users](#users)
|
||||||
|
- [Sessions](#sessions)
|
||||||
|
- [Upgrading](#upgrading)
|
||||||
|
- [Container health](#container-health)
|
||||||
|
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
|
||||||
|
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
|
||||||
|
- [Full backup and restore](#full-backup-and-restore)
|
||||||
|
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||||
|
- [Domain administration](#domain-administration)
|
||||||
|
- [Domains page](#domains-page)
|
||||||
|
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||||||
|
- [IP warmup](#ip-warmup)
|
||||||
|
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
|
||||||
|
- [Deliveries](#deliveries)
|
||||||
|
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
|
||||||
|
|
||||||
## Reverse proxy (mandatory)
|
## Installation
|
||||||
|
|
||||||
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
|
### Ports
|
||||||
proxy in front of it is not optional. The proxy is also the project's only
|
|
||||||
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
|
|
||||||
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
|
|
||||||
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
|
|
||||||
service share one hostname — the common case — it's genuinely one certificate
|
|
||||||
serving both.
|
|
||||||
|
|
||||||
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
|
||||||
|
(smtps) is always active. Port **587** is published even when
|
||||||
|
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
|
||||||
|
but it can look like an open port in external scans.
|
||||||
|
|
||||||
| Proxy | Where certs live | Fragment |
|
### Local trial
|
||||||
|---|---|---|
|
|
||||||
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
|
||||||
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
|
||||||
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
|
||||||
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
|
||||||
|
|
||||||
Apache is the recommended default because the certbot Apache plugin already
|
|
||||||
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
|
||||||
extra moving parts between "certificate issued" and "Postfix can read it."
|
|
||||||
|
|
||||||
**The proxy needs no security configuration of its own.** The panel emits its
|
|
||||||
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
|
||||||
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
|
|
||||||
that's easy to get wrong lives in the service rather than in a config file
|
|
||||||
somebody edits under pressure. There is exactly one thing the proxy must do:
|
|
||||||
**pass the original `Host` header through**. All four fragments above already
|
|
||||||
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
|
||||||
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
|
||||||
reject every form submission as cross-origin — the log says so explicitly,
|
|
||||||
printing the `Origin` and `Host` it compared.
|
|
||||||
|
|
||||||
## Local trial
|
|
||||||
|
|
||||||
The [README quick start](../README.md#quick-start) runs a single container
|
The [README quick start](../README.md#quick-start) runs a single container
|
||||||
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
|
||||||
@@ -85,7 +83,99 @@ Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
|
|||||||
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
|
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
|
||||||
Clients must skip TLS verification — the cert is not from a public CA.
|
Clients must skip TLS verification — the cert is not from a public CA.
|
||||||
|
|
||||||
## Environment variables
|
### Initial setup
|
||||||
|
|
||||||
|
On first start the one-time setup URL is printed in the container log
|
||||||
|
(`docker compose logs -f`) and written to `/data/setup-token` inside the
|
||||||
|
container — `./data/setup-token` on the host, mode `0600` — then deleted when
|
||||||
|
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
|
||||||
|
token, not a query string), valid for ten minutes. Open it to choose the
|
||||||
|
administrator username and password — until then the panel has no login. If
|
||||||
|
this host ships container logs to a central aggregator, prefer reading the
|
||||||
|
file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec selfpost cat /data/setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full deployment
|
||||||
|
|
||||||
|
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
|
||||||
|
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
|
||||||
|
only through a [reverse proxy](#reverse-proxy-mandatory) on 443 — port 8080 is
|
||||||
|
bound to localhost in the default compose file.
|
||||||
|
|
||||||
|
| Artefact | Path |
|
||||||
|
|---|---|
|
||||||
|
| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) |
|
||||||
|
| Environment template | [deploy/.env.example](../deploy/.env.example) |
|
||||||
|
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||||
|
| nginx | [deploy/nginx/](../deploy/nginx/) |
|
||||||
|
| Caddy | [deploy/caddy/](../deploy/caddy/) |
|
||||||
|
| Traefik | [deploy/traefik/](../deploy/traefik/) |
|
||||||
|
|
||||||
|
**1. Fetch the base files.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p selfpost/data selfpost/certs && cd selfpost
|
||||||
|
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
|
||||||
|
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
|
||||||
|
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
|
||||||
|
your provider and the certificate your proxy will obtain. See [Environment
|
||||||
|
variables](#environment-variables) for the full list.
|
||||||
|
|
||||||
|
**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy
|
||||||
|
(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. 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).
|
||||||
|
|
||||||
|
**3. Start SelfPost.** If you used Apache on the host (the recommended
|
||||||
|
option), start only the base compose file from your `selfpost/` directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The nginx/Caddy/Traefik fragments 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 (see
|
||||||
|
[Initial setup](#initial-setup)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs selfpost 2>&1 | grep -m1 'http'
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat ./data/setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. DNS and sending.** Before sending real mail:
|
||||||
|
|
||||||
|
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
|
||||||
|
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||||
|
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
|
||||||
|
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
|
||||||
|
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
|
||||||
|
|
||||||
|
#### Fixed image tag
|
||||||
|
|
||||||
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
|
deliberately never `:latest`. The current pin is `1.3.0`. Intermediate
|
||||||
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
|
image was published. Pinning matters because of the backup version check (see
|
||||||
|
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||||||
|
embedded version and the image tag that produced it are the same value by
|
||||||
|
construction (the release CI stamps both from one git tag — see
|
||||||
|
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
||||||
|
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
||||||
|
deliberately, not by riding a moving target — see [Upgrading](#upgrading).
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
|
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
|
||||||
`docker-compose.yml`. The table below lists every variable an operator is
|
`docker-compose.yml`. The table below lists every variable an operator is
|
||||||
@@ -95,7 +185,7 @@ expected to set; defaults match the code exactly.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||||
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting](#rate-limiting). | `100` | `.env` |
|
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
|
||||||
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
|
||||||
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
|
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
|
||||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||||
@@ -106,118 +196,128 @@ TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
|
|||||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
|
||||||
bind mount — configure the mount, not these variables.
|
bind mount — configure the mount, not these variables.
|
||||||
|
|
||||||
**Internal variables (not part of the operator interface).** The following are
|
The image also reads a number of internal, non-operator env vars (paths,
|
||||||
read by the panel or startup scripts but are not meant to be changed in a
|
timeouts, tuning) — not part of this interface; see
|
||||||
normal deployment; documenting them here avoids treating accidental overrides as
|
[architecture.md § Configuration](architecture.md#configuration) if you need
|
||||||
supported configuration:
|
them.
|
||||||
|
|
||||||
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
|
### Reverse proxy (mandatory)
|
||||||
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
|
|
||||||
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
|
|
||||||
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
|
|
||||||
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
|
|
||||||
here has to be matched in `build/postfix-config.sh`),
|
|
||||||
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
|
|
||||||
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
|
|
||||||
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
|
|
||||||
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
|
|
||||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
|
||||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
|
||||||
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
|
||||||
desyncs the map Postfix reads from the file the panel maintains).
|
|
||||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
|
||||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
|
||||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
|
||||||
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
|
|
||||||
`postfix reload` to pick up renewed certificates),
|
|
||||||
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
|
|
||||||
hours; logrotate keeps 14 rotated files on a daily schedule, and each
|
|
||||||
rotation triggers `postfix reload`).
|
|
||||||
|
|
||||||
## DNS setup
|
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
|
||||||
|
proxy in front of it is not optional. The proxy is also the project's only
|
||||||
|
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
|
||||||
|
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
|
||||||
|
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
|
||||||
|
service share one hostname — the common case — it's genuinely one certificate
|
||||||
|
serving both.
|
||||||
|
|
||||||
Two different scopes — don't confuse them:
|
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
|
||||||
|
|
||||||
**Server level (once, for the machine itself):**
|
| Proxy | Where certs live | Fragment |
|
||||||
- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most
|
|---|---|---|
|
||||||
receiving mail servers weigh this heavily; get it from whoever assigns the IP
|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
|
||||||
(hosting provider's panel/support), not from your own DNS zone.
|
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
|
||||||
|
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
|
||||||
|
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
|
||||||
|
|
||||||
**Domain level (for *every* sending domain you add in the panel):**
|
Apache is the recommended default because the certbot Apache plugin already
|
||||||
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
|
||||||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
extra moving parts between "certificate issued" and "Postfix can read it."
|
||||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
|
||||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
|
||||||
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
|
||||||
only, safe to publish immediately). On a send-only relay the sending domain
|
|
||||||
often has no inbox, so `rua=` is optional — configure a default report address
|
|
||||||
in *Settings* or per domain when you have a mailbox that receives inbound mail
|
|
||||||
elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that
|
|
||||||
hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …)
|
|
||||||
cannot be used as external report destinations.
|
|
||||||
|
|
||||||
Skipping any of the three per-domain records is the single most common reason
|
**The proxy needs no security configuration of its own.** The panel emits its
|
||||||
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
|
||||||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
|
||||||
panel, add its DNS records at the same time**, not later.
|
that's easy to get wrong lives in the service rather than in a config file
|
||||||
|
somebody edits under pressure. There is exactly one thing the proxy must do:
|
||||||
|
**pass the original `Host` header through**. All four fragments above already
|
||||||
|
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
|
||||||
|
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
|
||||||
|
reject every form submission as cross-origin — the log says so explicitly,
|
||||||
|
printing the `Origin` and `Host` it compared.
|
||||||
|
|
||||||
The panel checks both scopes for you and tells you what is actually published:
|
In every case the proxy terminates HTTPS for the panel; the resulting
|
||||||
the *Status* page verifies the server's hostname and its reverse record
|
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
|
||||||
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
|
||||||
card comparing the published DKIM record against the key this server signs with,
|
|
||||||
plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation
|
|
||||||
records. Results are cached for a few minutes;
|
|
||||||
use *Re-check* right after publishing a record. The SPF check is deliberately
|
|
||||||
shallow — it looks for a mechanism that literally covers this server's address
|
|
||||||
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
|
||||||
server through an include is reported as "cannot tell" rather than as a failure.
|
|
||||||
|
|
||||||
## IP warmup
|
**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:
|
||||||
|
|
||||||
A brand-new IP has no sending history, so receiving servers are cautious with
|
```sh
|
||||||
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
|
sudo certbot --apache -d mail.example.com
|
||||||
a domain, increase gradually over days/weeks rather than sending everything on
|
```
|
||||||
day one, and check the IP against major blocklists (Spamhaus and similar)
|
|
||||||
before and during warmup. This is inherent to how mail reputation works on the
|
|
||||||
public internet, not something SelfPost's configuration can shortcut.
|
|
||||||
|
|
||||||
## Operations
|
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.
|
||||||
|
|
||||||
|
## Instance administration
|
||||||
|
|
||||||
After sign-in the panel opens on **Status** — the place to answer "is the
|
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||||
service healthy and will mail be accepted?"
|
service healthy and will mail be accepted?"
|
||||||
|
|
||||||
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
|
### Status
|
||||||
TLS certificate validity and expiry, milter socket presence, and a short
|
|
||||||
Postfix queue summary. The **Machine** card adds the resource usage of the
|
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
||||||
host underneath — processor (with the load average), memory and swap, and
|
certificate validity and expiry, milter socket presence, and a short Postfix
|
||||||
per-interface network throughput and totals — read from the kernel's
|
queue summary. The **Machine** card adds the resource usage of the host
|
||||||
counters; CPU and throughput are measured between refreshes, so they appear
|
underneath — processor (core and thread counts), memory and swap, and
|
||||||
one refresh after the page opens. A fully busy processor or a machine out of
|
per-interface network throughput and totals — read from the kernel's
|
||||||
memory is a warning here, because both delay or kill the mail path;
|
counters; CPU and throughput are measured between refreshes, so they appear
|
||||||
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
one refresh after the page opens. A fully busy processor or a machine out of
|
||||||
against the PTR record the internet publishes for this server's IP
|
memory is a warning here, because both delay or kill the mail path;
|
||||||
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The
|
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
|
||||||
**Reload configuration** button re-applies OpenDKIM tables and the Postfix
|
against the PTR record the internet publishes for this server's IP
|
||||||
sender map from the database — use it if daemons drifted from what the panel
|
(forward-confirmed reverse DNS) — see
|
||||||
shows after manual edits under `/data`.
|
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
|
||||||
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
|
||||||
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
|
Postfix sender map from the database — use it if daemons drifted from what
|
||||||
(level 2) are configured here. *Export domain* writes a single-domain archive;
|
the panel shows after manual edits under `/data`.
|
||||||
*Import a domain* on the Backup page reads one back in.
|
|
||||||
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
### Mail queue and System log
|
||||||
by domain and application. A row identifies its message and nothing more —
|
|
||||||
time, sender, recipient, subject and status `queued` (accepted, not yet
|
|
||||||
delivered), `sent` (handed off successfully), `deferred` (Postfix is retrying),
|
|
||||||
`bounced` (final failure), or `rejected` (refused — for example by a level-2
|
|
||||||
rate limit); *Details* opens that row's own page
|
|
||||||
(`/deliveries/{id}`). That page carries the sending domain, the application it
|
|
||||||
was submitted under, the Postfix queue id and the journal id, beside the
|
|
||||||
message's history — when it was accepted and what Postfix later reported for
|
|
||||||
the recipient — and, under both, the `mail.log` lines for its queue id: the
|
|
||||||
connection to the receiving server, the server's reply, and the status that
|
|
||||||
reply was filed as. Rows outlive `mail.log`, so an older message's lines may
|
|
||||||
have rotated away; the page says so. Retention is controlled by
|
|
||||||
`SEND_LOG_RETENTION_DAYS`.
|
|
||||||
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||||
trying to deliver or deferring.
|
trying to deliver or deferring.
|
||||||
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
||||||
@@ -226,112 +326,206 @@ service healthy and will mail be accepted?"
|
|||||||
hours. It lives in the data volume, so it survives a container recreate along
|
hours. It lives in the data volume, so it survives a container recreate along
|
||||||
with the rest of the state — `./data/log/` on the host — but it is *not*
|
with the rest of the state — `./data/log/` on the host — but it is *not*
|
||||||
included in backups: it is diagnostics, not state.
|
included in backups: it is diagnostics, not state.
|
||||||
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
|
||||||
the domain-import form (`POST /domains/import`). See
|
|
||||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
|
||||||
- **Settings** (`/account`) — change the administrator username and/or password.
|
|
||||||
Application SASL logins are separate and are not changed here.
|
|
||||||
|
|
||||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
### Settings
|
||||||
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
|
|
||||||
|
`/settings` changes the signed-in user's username and/or password. **Global
|
||||||
|
administrators** also set the panel-wide default DMARC report address (`rua=`)
|
||||||
|
offered when a domain doesn't set its own — see
|
||||||
|
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
|
||||||
|
are separate and are not changed here.
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
`/users` (global administrator only) creates, edits, and deletes panel users.
|
||||||
|
There are two roles:
|
||||||
|
|
||||||
|
- **Global administrator** — full access to every page and every domain,
|
||||||
|
including Users, Backup, Status, Mail queue, and System log.
|
||||||
|
- **Domain-admin** — scoped to one or more domains assigned by a global
|
||||||
|
administrator. Sees only those domains' pages, applications, and
|
||||||
|
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
|
||||||
|
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
|
||||||
|
reachable (404). A domain-admin can *export* the
|
||||||
|
domains assigned to them — see
|
||||||
|
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
|
||||||
|
|
||||||
|
The panel refuses to remove or demote the **last** global administrator, so
|
||||||
|
it can never end up with none.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
A login survives a container restart: sessions live in SQLite, not in
|
||||||
|
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
|
||||||
seven days) with no absolute lifetime cap — an admin who keeps using the panel
|
seven days) with no absolute lifetime cap — an admin who keeps using the panel
|
||||||
stays signed in indefinitely. HTMX polling on the monitoring screens
|
stays signed in indefinitely. HTMX polling on the monitoring screens
|
||||||
(Deliveries, Mail queue, System log, and the Status health fragment) does
|
(Deliveries, Mail queue, System log, and the Status health fragment) does
|
||||||
**not** count as activity, so an auto-refreshing tab left open will not keep a
|
**not** count as activity, so an auto-refreshing tab left open will not keep a
|
||||||
session alive forever. Changing the password signs out every other session but
|
session alive forever. Changing **your own** password on `/settings` signs out
|
||||||
leaves the current browser signed in.
|
every other session for that user but leaves the current browser signed in.
|
||||||
|
Signing out (`POST /logout`) ends only the current session — other browsers or
|
||||||
|
tabs for the same user keep working until their session rows expire.
|
||||||
|
|
||||||
**Upgrading.** Bump the pinned image tag in `docker-compose.yml` to the target
|
### Upgrading
|
||||||
release, then `docker compose up -d`. The backup version check requires the
|
|
||||||
running image to match the version that created a full backup — see [Fixed image
|
Bump the pinned image tag in `docker-compose.yml` to the target release, then
|
||||||
|
`docker compose up -d`. The backup version check requires the running image
|
||||||
|
to match the version that created a full backup — see [Fixed image
|
||||||
tag](#fixed-image-tag).
|
tag](#fixed-image-tag).
|
||||||
|
|
||||||
**Container health.** The image declares a Docker `HEALTHCHECK` that probes
|
### Container health
|
||||||
`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when
|
|
||||||
OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord;
|
|
||||||
otherwise `503 unhealthy`. This catches a dead mail path that would still leave
|
|
||||||
the HTTP server up, but it does **not** verify TLS certificates, DNS records,
|
|
||||||
or end-to-end delivery — use the authenticated **Status** page for that. External
|
|
||||||
monitoring can use the same endpoint through the reverse proxy if you expose it,
|
|
||||||
or poll `docker inspect` health state on the host.
|
|
||||||
|
|
||||||
**First-time setup link.** On first start the one-time setup URL is printed in
|
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
|
||||||
the container log (`docker compose logs -f`) and written to `/data/setup-token`
|
8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
|
||||||
inside the container — `./data/setup-token` on the host, mode `0600` — then
|
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
|
||||||
deleted when setup completes. The link is
|
catches a dead mail path that would still leave the HTTP server up, but it
|
||||||
`https://<SELFPOST_HOSTNAME>/setup/<token>` (path token, not a query string),
|
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
|
||||||
valid for ten minutes. If this host ships container logs to a central
|
use the authenticated [Status](#status) page for that. External monitoring
|
||||||
aggregator, prefer reading the file:
|
can use the same endpoint through the reverse proxy if you expose it, or poll
|
||||||
|
`docker inspect` health state on the host.
|
||||||
|
|
||||||
```sh
|
### Server-level DNS (PTR/rDNS)
|
||||||
docker compose exec selfpost cat /data/setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate limiting
|
Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at
|
||||||
|
its mail hostname. Most receiving mail servers weigh this heavily; get it
|
||||||
|
from whoever assigns the IP (hosting provider's panel/support), not from
|
||||||
|
your own DNS zone.
|
||||||
|
|
||||||
SelfPost applies two independent limits; both can refuse a submission, but only
|
The [Status](#status) page verifies the server's hostname against this
|
||||||
level 2 writes a `rejected` row in the send log.
|
record (forward-confirmed reverse DNS). Results are cached for about one
|
||||||
|
minute; use *Re-check* right after publishing a record.
|
||||||
|
|
||||||
**Level 1 (IP backstop)** — always on, configured via `.env`:
|
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
|
||||||
|
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
|
||||||
|
|
||||||
|
### Rate limiting — level 1 (IP backstop)
|
||||||
|
|
||||||
|
SelfPost applies two independent layers of rate limiting; both can refuse a
|
||||||
|
submission, but only level 2 (domain/application, see
|
||||||
|
[Domain administration](#rate-limiting--level-2-domain-and-application))
|
||||||
|
writes a `rejected` row in the send log. Level-2 ceilings set in the panel
|
||||||
|
cannot exceed level 1 (the panel shows the level-1 values and rejects higher
|
||||||
|
numbers).
|
||||||
|
|
||||||
|
Level 1 is always on, configured via `.env`:
|
||||||
|
|
||||||
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
|
||||||
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||||
|
|
||||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||||
journal-milter (level 2) is down.
|
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||||
|
|
||||||
**Level 2 (per domain / per application)** — optional, configured in the panel
|
### Full backup and restore
|
||||||
on each domain's page or on an individual application. You set a message
|
|
||||||
ceiling, a time window, and optionally restrict the limit to specific client
|
|
||||||
IPs; an empty IP list means the differentiated limit does not apply. When
|
|
||||||
exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as
|
|
||||||
`rejected`.
|
|
||||||
|
|
||||||
## Backup, restore, and moving a single domain
|
**Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
|
||||||
|
all applications' SASL credentials, `manifest.json` with the version that
|
||||||
|
created it): panel button (*Backup* → *Full backup*), or from the host:
|
||||||
|
|
||||||
Two related but distinct operations
|
```sh
|
||||||
([architecture.md](architecture.md) § Persistence):
|
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
|
**Restore** means unpacking that archive into a fresh `/data` bind mount and
|
||||||
all applications' SASL credentials, `manifest.json` with the version that
|
starting a container of the **exact same image version** that created it —
|
||||||
created it): panel button (*Backup* → *Full backup*), or from the
|
SelfPost refuses to start otherwise and tells you which tag to use. On the
|
||||||
host:
|
first successful start after restore, `manifest.json` from the archive is
|
||||||
```sh
|
**deleted** — it guards only that one boot, so a later in-place upgrade is
|
||||||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
not blocked. On that same first boot the panel also runs one **Resync** —
|
||||||
```
|
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both
|
||||||
**Restore** means unpacking that archive into a fresh `/data` bind mount and
|
daemons are reloaded, healing any drift between the extracted files and the
|
||||||
starting a container of the **exact same image version** that created it —
|
database (the Status page's *Reload configuration* button runs the same step
|
||||||
SelfPost refuses to start otherwise and tells you which tag to use. On the
|
on demand). This is why the compose file pins a fixed tag rather than
|
||||||
first successful start after restore, `manifest.json` from the archive is
|
`:latest`: without a known version, there'd be no way to tell which image
|
||||||
**deleted** — it guards only that one boot, so a later in-place upgrade is
|
restoring a given backup actually requires (see [Fixed image
|
||||||
not blocked. This is why the compose file pins a fixed tag rather than
|
tag](#fixed-image-tag)).
|
||||||
`:latest`: without a known version, there'd be no way to tell which image
|
|
||||||
restoring a given backup actually requires.
|
|
||||||
|
|
||||||
**Alternative: archive `./data` while stopped.** If the service can be taken
|
**Restoring in place** (same host — recovering from data loss, or rolling
|
||||||
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
|
back after a bad change):
|
||||||
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
|
|
||||||
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
|
|
||||||
bulk of the archive; add `--exclude=./data/log` if you only want the state.
|
|
||||||
Do **not** tar `./data` while
|
|
||||||
the container is running: the database uses WAL mode and a naive copy can
|
|
||||||
capture an inconsistent snapshot. The panel/CLI backup remains preferable when
|
|
||||||
you cannot afford downtime because it takes a consistent SQLite snapshot via
|
|
||||||
the Backup API on a live container.
|
|
||||||
|
|
||||||
- **Export/import a single domain** (domain page → *Export domain* to write the
|
```sh
|
||||||
file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working**
|
# 1. Stop the instance being replaced
|
||||||
SASL passwords — to a different SelfPost instance without regenerating
|
docker compose down
|
||||||
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full
|
|
||||||
restore, this works across different hostnames/instances.
|
|
||||||
|
|
||||||
Both files are **secrets** — they contain the admin password hash (full
|
# 2. Move the current /data aside rather than deleting it, start from empty
|
||||||
backup) or working application credentials (domain export) in the clear or in
|
mv ./data ./data.before-restore
|
||||||
directly reversible form. Treat them like any other credential material:
|
mkdir ./data
|
||||||
restrict who can read them, don't email them around — and encrypt them, which
|
|
||||||
SelfPost can do for you.
|
|
||||||
|
|
||||||
### Encrypting a backup or export
|
# 3. Unpack the backup into the fresh directory
|
||||||
|
tar xzf selfpost-backup.tar.gz -C ./data
|
||||||
|
|
||||||
|
# 4. docker-compose.yml must already pin the exact tag the backup was made
|
||||||
|
# with — check the archive's manifest if unsure:
|
||||||
|
tar xzf selfpost-backup.tar.gz -O manifest.json
|
||||||
|
|
||||||
|
# 5. Start it and watch the boot
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f selfpost
|
||||||
|
```
|
||||||
|
|
||||||
|
A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||||
|
the panel exits with a message naming the tag to use, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
||||||
|
-d` again — the manifest is still there because the failed boot never got to
|
||||||
|
delete it.
|
||||||
|
|
||||||
|
**Moving to a different host** is the same five steps, just starting cold:
|
||||||
|
bring the compose files and the correct pinned image tag to the new host
|
||||||
|
(step 1 of [Full deployment](#full-deployment)), put the backup archive in
|
||||||
|
place of step 3 above, then redo the reverse-proxy/TLS and DNS steps of a
|
||||||
|
[Full deployment](#full-deployment) — the PTR record and the certificate both
|
||||||
|
belong to the old IP/host and have to be reissued for the new one; nothing in
|
||||||
|
the backup carries them.
|
||||||
|
|
||||||
|
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
||||||
|
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
||||||
|
does not read `/data` and performs no version check. Start one normally
|
||||||
|
(step 5, but on an empty `/data` you haven't unpacked yet), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop it, wipe `/data` again, and continue from step 2 above with the
|
||||||
|
resulting `.tar.gz` — see [Encrypting a backup or
|
||||||
|
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
||||||
|
options.
|
||||||
|
|
||||||
|
Restoring an archive taken **before** a session row was removed can bring
|
||||||
|
that session back: session rows travel with the backup, and a browser that
|
||||||
|
still holds the matching cookie is signed in again on the next request if the
|
||||||
|
restored row's idle expiry has not passed. `POST /logout` removes only the
|
||||||
|
current session; there is no "logout everywhere". Changing your own password
|
||||||
|
on `/settings` deletes your other sessions, but a global administrator
|
||||||
|
resetting another user's password on `/users` does not invalidate that user's
|
||||||
|
existing sessions.
|
||||||
|
|
||||||
|
**Alternative: archive `./data` while stopped.** If the service can be taken
|
||||||
|
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
|
||||||
|
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
|
||||||
|
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
|
||||||
|
bulk of the archive; add `--exclude=./data/log` if you only want the state.
|
||||||
|
Do **not** tar `./data` while the container is running: the database uses
|
||||||
|
WAL mode and a naive copy can capture an inconsistent snapshot. The
|
||||||
|
panel/CLI backup remains preferable when you cannot afford downtime because
|
||||||
|
it takes a consistent SQLite snapshot via the Backup API on a live
|
||||||
|
container.
|
||||||
|
|
||||||
|
See also [Exporting and importing a single
|
||||||
|
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
||||||
|
operation that also lives on the *Backup* page (`/backup`).
|
||||||
|
|
||||||
|
Both a full backup and a domain export are **secrets** — they contain the
|
||||||
|
admin password hash (full backup) or working application credentials (domain
|
||||||
|
export) in the clear or in directly reversible form. Treat them like any
|
||||||
|
other credential material: restrict who can read them, don't email them
|
||||||
|
around — and encrypt them, which SelfPost can do for you.
|
||||||
|
|
||||||
|
#### Encrypting a backup or export
|
||||||
|
|
||||||
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
|
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
|
||||||
file that comes down is an encrypted envelope instead of the plain archive:
|
file that comes down is an encrypted envelope instead of the plain archive:
|
||||||
@@ -371,21 +565,121 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
|
|||||||
|
|
||||||
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
||||||
|
|
||||||
## Published ports
|
## Domain administration
|
||||||
|
|
||||||
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
|
### Domains page
|
||||||
(smtps) is always active. Port **587** is published even when
|
|
||||||
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
|
|
||||||
but it can look like an open port in external scans.
|
|
||||||
|
|
||||||
## Fixed image tag
|
`/domains` lists sending domains and hosts the add-domain form (**global
|
||||||
|
administrator only**). Domain administrators see only domains assigned to
|
||||||
|
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
|
||||||
|
applications. Per-domain rate limits (level 2) and trusted-IP application
|
||||||
|
overrides are configured here — see [Rate limiting —
|
||||||
|
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
|
||||||
|
writes a single-domain archive; *Import a domain* on the Backup page reads
|
||||||
|
one back in (**global administrator only**) — see [Exporting and importing a single
|
||||||
|
domain](#exporting-and-importing-a-single-domain).
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
### Domain-level DNS (SPF, DKIM, DMARC)
|
||||||
deliberately never `:latest`. The current pin is `1.1.0`. Intermediate
|
|
||||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
For *every* sending domain you add in the panel:
|
||||||
image was published. Pinning matters because of the backup version check above:
|
|
||||||
the panel binary's embedded version and the image tag that produced it are the
|
- **SPF** — a TXT record on the domain authorizing this server to send on its
|
||||||
same value by construction (the release CI stamps both from one git tag — see
|
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||||
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||||
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||||
deliberately, not by riding a moving target.
|
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||||
|
only, safe to publish immediately). On a send-only relay the sending domain
|
||||||
|
often has no inbox, so `rua=` is optional — configure a default report address
|
||||||
|
in *Settings* (see [Settings](#settings)) or per domain when you have a
|
||||||
|
mailbox that receives inbound mail elsewhere. If `rua=` points at another
|
||||||
|
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||||||
|
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||||
|
report destinations.
|
||||||
|
|
||||||
|
Skipping any of the three records is the single most common reason mail
|
||||||
|
lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||||
|
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||||
|
panel, add its DNS records at the same time**, not later.
|
||||||
|
|
||||||
|
Each domain's page shows a *DNS status* card comparing the published DKIM
|
||||||
|
record against the key this server signs with, plus the domain's SPF,
|
||||||
|
DMARC, and (when configured) DMARC report-authorisation records. Results are
|
||||||
|
cached for a few minutes; use *Re-check* right after publishing a record.
|
||||||
|
The SPF check is deliberately shallow — it looks for a mechanism that
|
||||||
|
literally covers this server's address and does not follow `include:` or
|
||||||
|
`redirect=`, so a record that authorizes the server through an include is
|
||||||
|
reported as "cannot tell" rather than as a failure.
|
||||||
|
|
||||||
|
Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope
|
||||||
|
— see [Server-level DNS](#server-level-dns-ptrrdns).
|
||||||
|
|
||||||
|
### IP warmup
|
||||||
|
|
||||||
|
A brand-new IP has no sending history, so receiving servers are cautious with
|
||||||
|
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
|
||||||
|
a domain, increase gradually over days/weeks rather than sending everything on
|
||||||
|
day one, and check the IP against major blocklists (Spamhaus and similar)
|
||||||
|
before and during warmup. This is inherent to how mail reputation works on the
|
||||||
|
public internet, not something SelfPost's configuration can shortcut.
|
||||||
|
|
||||||
|
### Rate limiting — level 2 (domain and application)
|
||||||
|
|
||||||
|
Level 2 is optional, configured on each domain's page, and layers on top of
|
||||||
|
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
|
||||||
|
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
|
||||||
|
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
|
||||||
|
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
|
||||||
|
`rejected`.
|
||||||
|
|
||||||
|
**Level 2 — domain** — a message ceiling and window for **every** client IP
|
||||||
|
sending as that domain. When unset, only level 1 applies for non-privileged
|
||||||
|
senders.
|
||||||
|
|
||||||
|
**Level 2 — application (trusted IPs)** — optional override on an
|
||||||
|
application: list one or more client IPs and a ceiling **strictly above**
|
||||||
|
the domain limit (still ≤ level 1). Connections from those IPs use the
|
||||||
|
application ceiling and skip the domain check. Other IPs stay under the
|
||||||
|
domain limit (or level 1 alone). An application override without trusted
|
||||||
|
IPs is inactive.
|
||||||
|
|
||||||
|
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
||||||
|
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||||
|
a store error, or the connecting client's IP is not available to the
|
||||||
|
milter, level 2 is skipped and the message is accepted rather than held up.
|
||||||
|
Level 1 is the backstop that keeps working even when level 2 cannot run.
|
||||||
|
|
||||||
|
### Deliveries
|
||||||
|
|
||||||
|
`/deliveries` is a searchable send log with server-side filters by domain
|
||||||
|
and application. A row identifies its message and nothing more — time,
|
||||||
|
sender, recipient, subject and status `queued` (accepted, not yet
|
||||||
|
delivered), `sent` (handed off successfully), `deferred` (Postfix is
|
||||||
|
retrying), `bounced` (final failure), or `rejected` (refused — for example
|
||||||
|
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
||||||
|
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
|
||||||
|
the sending domain, the application it was submitted under, the Postfix
|
||||||
|
queue id and the journal id, beside the message's history — when it was
|
||||||
|
accepted and what Postfix later reported for the recipient — and, under
|
||||||
|
both, the `mail.log` lines for its queue id: the connection to the
|
||||||
|
receiving server, the server's reply, and the status that reply was filed
|
||||||
|
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
||||||
|
away; the page says so. Retention is controlled by
|
||||||
|
`SEND_LOG_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
### Exporting and importing a single domain
|
||||||
|
|
||||||
|
Domain page → *Export domain* to write the file, *Backup* → *Import a
|
||||||
|
domain* to read it back in. This moves one domain — its DKIM key and its
|
||||||
|
applications' **working** SASL passwords — to a different SelfPost instance
|
||||||
|
without regenerating anything, so DNS (the DKIM TXT record) doesn't need to
|
||||||
|
change. Unlike a full restore (see [Full backup and
|
||||||
|
restore](#full-backup-and-restore)), this works across different
|
||||||
|
hostnames/instances. *Import* is global-administrator only; *export* is
|
||||||
|
available to any user who can access the domain, **including a domain-admin**
|
||||||
|
for a domain assigned to them — so a domain-admin can walk away with that
|
||||||
|
domain's working SASL passwords in the clear. Weigh that when deciding which
|
||||||
|
domains to assign to a domain-admin account.
|
||||||
|
|
||||||
|
A domain export is a secret in the same way a full backup is, and can be
|
||||||
|
encrypted the same way — see [Encrypting a backup or
|
||||||
|
export](#encrypting-a-backup-or-export).
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ operator.
|
|||||||
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
|
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
|
||||||
- Panel page and/or per-domain section: recent reports, third-party senders,
|
- Panel page and/or per-domain section: recent reports, third-party senders,
|
||||||
delivery health of report ingestion.
|
delivery health of report ingestion.
|
||||||
- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates;
|
- Reuse the `dmarc_report_email` setting (moved off the old `admin` table into
|
||||||
|
`settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates;
|
||||||
when enabled, suggest a SelfPost-hosted report address.
|
when enabled, suggest a SelfPost-hosted report address.
|
||||||
|
|
||||||
**Out:**
|
**Out:**
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
# Plan: domain-admin (domain administrator role)
|
|
||||||
|
|
||||||
**Status:** agreed
|
|
||||||
**Version:** target bump **1.x** MINOR, given a compatible migration of the
|
|
||||||
current administrator into a global one.
|
|
||||||
**Order:** recommended after [web-split](web-split.md) (done), before
|
|
||||||
[inbound-relay](inbound-relay.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
Today the panel has exactly one subject: `RequireAuth` is a boolean gate, not a
|
|
||||||
role ([web.go](../../internal/web/web.go) — the
|
|
||||||
`mux.Handle("/", s.auth.RequireAuth(authed))` wrapper), and the session carries
|
|
||||||
nothing beyond the fact of being signed in.
|
|
||||||
|
|
||||||
Two panel roles:
|
|
||||||
|
|
||||||
| Role | Scope |
|
|
||||||
|------|-------|
|
|
||||||
| **global** | Full panel except nothing new — same powers as today's single admin |
|
|
||||||
| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
|
|
||||||
|
|
||||||
For each assigned domain, a domain-admin can:
|
|
||||||
|
|
||||||
- applications (create, sender mode, password regeneration, delete, L2 limit);
|
|
||||||
- DKIM/DNS status and recheck;
|
|
||||||
- per-domain DMARC `rua=` (inherit / none / custom) — full control on the
|
|
||||||
domain page;
|
|
||||||
- send log filtered to assigned domains;
|
|
||||||
- domain export (encrypted `.spde` optional, same as today);
|
|
||||||
- domain-level L2 rate limit.
|
|
||||||
|
|
||||||
What stays **global-only** (domain-admin gets 404 or redirect):
|
|
||||||
|
|
||||||
- adding and removing domains;
|
|
||||||
- domain import;
|
|
||||||
- creating/editing/deleting panel users and assigning domains;
|
|
||||||
- `/reload`;
|
|
||||||
- full backup (`/backup` — all of `/data` including every domain's `sasldb2`);
|
|
||||||
- mail queue (`/mail-queue*`);
|
|
||||||
- system log tail (`/system-log*`);
|
|
||||||
- status page (`/status*`) — server-wide health, queue summary, reload, DNS
|
|
||||||
recheck of the **hostname**; same treatment as queue and system log.
|
|
||||||
|
|
||||||
Domain-admin **self-service** on `/account`: username and password only (not
|
|
||||||
global DMARC report email).
|
|
||||||
|
|
||||||
## Why this extends v1.0
|
|
||||||
|
|
||||||
[product.md](../product.md) puts "multiple panel users, roles" out of scope
|
|
||||||
(one administrator). A second subject is a deliberate widening of the project's
|
|
||||||
boundary, as inbound-relay is.
|
|
||||||
|
|
||||||
The cost is phase-sized, not patch-sized:
|
|
||||||
|
|
||||||
- a users table and their binding to domains;
|
|
||||||
- the role in the session;
|
|
||||||
- authorisation in every handler (not only on the route — today `{id}`/`{aid}`
|
|
||||||
are checked for nothing beyond existence);
|
|
||||||
- reworking first-run setup and password change for several users;
|
|
||||||
- accounting for the new subject in backup and domain export.
|
|
||||||
|
|
||||||
*(The earlier wording of this item — "2FA and multiple administrators" — has
|
|
||||||
been replaced: 2FA is off the table, and "multiple administrators" is narrowed
|
|
||||||
to one specific role, because what is needed is not a second all-powerful admin
|
|
||||||
but limited access for the owner of one or several domains, with the list set
|
|
||||||
by the global administrator.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schema and migration
|
|
||||||
|
|
||||||
**New migration** `0005_panel_users.sql`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
|
||||||
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_domains (
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (user_id, domain_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Migrate existing administrator → global user (idempotent guard via admin count).
|
|
||||||
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
|
||||||
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
|
||||||
FROM admin WHERE id = 1;
|
|
||||||
|
|
||||||
DROP TABLE admin;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sessions:** keep `sessions.username` (no schema change). On login and
|
|
||||||
`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session
|
|
||||||
after username change behaves as today (`Lookup` fails → redirect login).
|
|
||||||
|
|
||||||
**Backup/restore:** full backup already snapshots `selfpost.db` via
|
|
||||||
`VACUUM INTO`; users and bindings restore with the DB. No manifest format change
|
|
||||||
required (same `selfpost-full-backup`).
|
|
||||||
|
|
||||||
**DMARC two levels:**
|
|
||||||
|
|
||||||
- **Global** `users.dmarc_report_email` — only on global user's `/account`;
|
|
||||||
default `rua=` when a domain uses *inherit*.
|
|
||||||
- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page
|
|
||||||
(existing handler); domain-admin never sees the global default field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Principal model
|
|
||||||
|
|
||||||
Request context carries a `Principal` (in `internal/web/auth`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Role string // "global" | "domain_admin"
|
|
||||||
|
|
||||||
type Principal struct {
|
|
||||||
ID int64
|
|
||||||
Username string
|
|
||||||
Role Role
|
|
||||||
Domains []int64 // assigned domain IDs; empty for global (meaning "all")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Helpers:
|
|
||||||
|
|
||||||
- `CurrentPrincipal(r)` — from context;
|
|
||||||
- `IsGlobal(p)` — `p.Role == "global"`;
|
|
||||||
- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`;
|
|
||||||
- `CanAccessApp(p, app)` — `CanAccessDomain(p, app.DomainID)`.
|
|
||||||
|
|
||||||
`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after
|
|
||||||
existence check; return 404 (not 403) to avoid leaking IDs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Route matrix
|
|
||||||
|
|
||||||
| Method | Path | global | domain_admin |
|
|
||||||
|--------|------|--------|--------------|
|
|
||||||
| GET | `/` | → `/status` | → `/domains` |
|
|
||||||
| GET | `/status`, `/status/fragment` | yes | **no** (404) |
|
|
||||||
| POST | `/status/recheck` | yes | **no** |
|
|
||||||
| GET | `/domains` | all domains | assigned only |
|
|
||||||
| POST | `/domains` | yes | **no** |
|
|
||||||
| POST | `/domains/import` | yes | **no** |
|
|
||||||
| GET | `/domains/{id}` | yes | assigned |
|
|
||||||
| POST | `/domains/{id}/dns-recheck` | yes | assigned |
|
|
||||||
| GET/POST | `/domains/{id}/delete` | yes | **no** |
|
|
||||||
| POST | `/domains/{id}/applications` | yes | assigned |
|
|
||||||
| POST | `/domains/{id}/ratelimit` | yes | assigned |
|
|
||||||
| POST | `/domains/{id}/dmarc` | yes | assigned |
|
|
||||||
| POST | `/domains/{id}/export` | yes | assigned |
|
|
||||||
| POST | `/applications/{aid}/*` | yes | if app in assigned domain |
|
|
||||||
| POST | `/reload` | yes | **no** |
|
|
||||||
| GET/POST | `/account` | username, password, global DMARC email | username, password only |
|
|
||||||
| GET/POST | `/backup` | yes | **no** |
|
|
||||||
| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains |
|
|
||||||
| GET | `/mail-queue*` | yes | **no** |
|
|
||||||
| GET | `/system-log*` | yes | **no** |
|
|
||||||
| GET | `/users` | list users | **no** |
|
|
||||||
| GET/POST | `/users/new` | create user | **no** |
|
|
||||||
| GET/POST | `/users/{uid}` | edit/delete user | **no** |
|
|
||||||
| POST | `/logout` | yes | yes |
|
|
||||||
|
|
||||||
**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned
|
|
||||||
domain set; dropdowns list only assigned domains/apps; reject `domain` query
|
|
||||||
param outside assignment; `HandleDelivery` checks log row's `domain` field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User management UI (global only)
|
|
||||||
|
|
||||||
New routes under `/users`:
|
|
||||||
|
|
||||||
- **List** — username, role, assigned domain names (or "all" for global).
|
|
||||||
- **Create** — username, password, role (`domain_admin` default), multi-select
|
|
||||||
domains (required when role is `domain_admin`).
|
|
||||||
- **Edit** — change password (optional), reassign domains, delete user.
|
|
||||||
- **Guards:** cannot delete the last `global` user; cannot demote self to
|
|
||||||
`domain_admin` without another global user; domain-admin role cannot access
|
|
||||||
these routes.
|
|
||||||
|
|
||||||
Templates: `users.html`, `user_form.html`; nav link visible only for global
|
|
||||||
users.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auth / setup / sessions
|
|
||||||
|
|
||||||
- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global**
|
|
||||||
user via `CreateGlobalUser`; `AdminExists` → `UserExists`.
|
|
||||||
- **Login:** authenticate against `users` by username + bcrypt.
|
|
||||||
- **Password change:** per-user `UpdateUser`; domain-admin cannot change
|
|
||||||
another user's password.
|
|
||||||
- **Session rename / destroy others:** unchanged behaviour keyed by username.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
[layout.html](../../internal/web/view/templates/layout.html) `nav` template:
|
|
||||||
|
|
||||||
- **global:** all items today (status, domains, deliveries, mail queue, system
|
|
||||||
log, backup, settings) + **Users**.
|
|
||||||
- **domain_admin:** domains, deliveries, settings only.
|
|
||||||
|
|
||||||
Pass `IsGlobal` (or `Principal`) into every rendered page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR);
|
|
||||||
note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in
|
|
||||||
this phase.
|
|
||||||
- **Export encryption:** optional password on domain export remains; full backup
|
|
||||||
encryption trigger ("second administrator") is satisfied by domain-admin
|
|
||||||
existing — no change required.
|
|
||||||
- **Authorization tests:** table-driven tests for global vs domain-admin on
|
|
||||||
representative handlers; explicit `{aid}` cross-domain mutation blocked.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation order
|
|
||||||
|
|
||||||
1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings).
|
|
||||||
2. Auth: login against `users`, `Principal` in context, setup creates global user.
|
|
||||||
3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`.
|
|
||||||
4. Route guards: global-only middleware or per-handler checks.
|
|
||||||
5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
|
|
||||||
6. User management handlers + templates.
|
|
||||||
7. Nav visibility + default redirect (`/`).
|
|
||||||
8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done when
|
|
||||||
|
|
||||||
- A global administrator and a domain-admin with different rights both work
|
|
||||||
through the panel; the domain-admin cannot reach past the **assigned**
|
|
||||||
domains;
|
|
||||||
- the current single admin migrates into a global one without losing access;
|
|
||||||
- backup/restore accounts for users and their bindings;
|
|
||||||
- `build`/`vet`/`test`/image green.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
|
|
||||||
else's domain;
|
|
||||||
- breaking setup or backup — that would be a semver major, not 1.x.
|
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
**Status:** agreed
|
**Status:** agreed
|
||||||
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
||||||
the implementation lands (do not fix a major in advance).
|
the implementation lands (do not fix a major in advance).
|
||||||
**Order:** recommended after [web-split](web-split.md) and
|
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
|
||||||
[domain-admin](domain-admin.md).
|
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
|
||||||
|
The domain-admin role
|
||||||
|
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
|
||||||
|
`[1.2.0]`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Plan: queue-retries (Postfix retry policy in the panel)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Date:** 2026-08-13
|
||||||
|
**Version:** patch; no schema, no configuration surface.
|
||||||
|
**Order:** small panel item; does not wait on inbound-relay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Show the operator how Postfix retries deferred mail: first retry delay, later
|
||||||
|
backoff cap, and how long a message stays in the queue before it bounces. The
|
||||||
|
numbers come from this container's effective Postfix config, not from
|
||||||
|
hard-coded copy.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- A static «How delivery retries work» card on `/mail-queue` (global
|
||||||
|
administrator). Not inside the HTMX poll fragment — the snapshot is taken at
|
||||||
|
panel start.
|
||||||
|
- The same human-readable intervals in `/deliveries/{id}` history for
|
||||||
|
`deferred` and `bounced` (domain administrators never see Mail queue).
|
||||||
|
- Operator docs: [guide.md](../guide.md) Mail queue bullet;
|
||||||
|
[architecture.md](../architecture.md) notes the one-shot `postconf -h` at
|
||||||
|
panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Changing Postfix retry parameters, or exposing them as panel settings
|
||||||
|
([product.md](../product.md): Postfix is used as-is).
|
||||||
|
- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no
|
||||||
|
attempt budget; it is time-based.
|
||||||
|
- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each
|
||||||
|
attempt is already on the delivery page's log table; the journal stores only
|
||||||
|
the last status.
|
||||||
|
- Duplicating the card on Status (summary + link to Mail queue already exist).
|
||||||
|
- Re-reading `postconf` on every HTTP request.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk
|
||||||
|
queue. SelfPost does not enqueue, retry, or deliver.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"]
|
||||||
|
postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"]
|
||||||
|
retry -->|sent| done["sent"]
|
||||||
|
retry -->|lifetime elapsed or 5xx| bounce["bounced"]
|
||||||
|
postfixQueue --> mailQueuePage["/mail-queue policy card"]
|
||||||
|
postfixQueue --> deliveryPage["/deliveries/id history copy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
[build/postfix-config.sh](../../build/postfix-config.sh) does not set
|
||||||
|
`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`,
|
||||||
|
`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`.
|
||||||
|
Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator
|
||||||
|
overrides them (`postconf -e` inside the container).
|
||||||
|
|
||||||
|
### Loading the numbers
|
||||||
|
|
||||||
|
Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go),
|
||||||
|
after `postfix-config.sh` has run):
|
||||||
|
|
||||||
|
```
|
||||||
|
postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time
|
||||||
|
```
|
||||||
|
|
||||||
|
`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not
|
||||||
|
written to the file. `postconf` is the effective config, including a manual
|
||||||
|
override.
|
||||||
|
|
||||||
|
- Fixed argv, no user input — same pattern as
|
||||||
|
[postfix.Queue](../../internal/postfix/queue.go) /
|
||||||
|
[security.md](../security.md).
|
||||||
|
- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not
|
||||||
|
call `postconf`.
|
||||||
|
- A live `postconf -e` is visible after the next panel (or container) restart.
|
||||||
|
While the process is up, the panel shows the start-up snapshot.
|
||||||
|
- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in
|
||||||
|
`internal/postfix`. Format human strings (`5 minutes`, `5 days`,
|
||||||
|
`about 1 hour 7 minutes`) in one place so the Mail queue card and
|
||||||
|
`deliveryEvents` cannot drift.
|
||||||
|
- If `postconf` is missing (unit tests on Windows, binary outside the
|
||||||
|
container): log a warning, fall back to Postfix 3.x compiled-in defaults
|
||||||
|
(`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests
|
||||||
|
stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on
|
||||||
|
`Config`. Do not fail panel start.
|
||||||
|
|
||||||
|
Typical stock values, for orientation only — the UI prints whatever
|
||||||
|
`postconf` returned:
|
||||||
|
|
||||||
|
| Parameter | Stock | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan |
|
||||||
|
| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) |
|
||||||
|
| `maximal_queue_lifetime` | `5d` | Then bounce |
|
||||||
|
| `delay_warning_time` | `0` | No delay warning to the sender |
|
||||||
|
|
||||||
|
## Panel copy
|
||||||
|
|
||||||
|
Mail queue card facts: first retry; later retries (doubling, capped);
|
||||||
|
kept in queue; then bounced. Short prose: there is no fixed attempt count; a
|
||||||
|
`deferred` message stays in this listing until it is delivered or the queue
|
||||||
|
lifetime runs out.
|
||||||
|
|
||||||
|
`deliveryEvents(row, policy)`:
|
||||||
|
|
||||||
|
- `deferred`: retries, first after X, then with increasing gaps up to Y, for
|
||||||
|
up to Z.
|
||||||
|
- `bounced`: or Postfix gave up after Z in the queue.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number.
|
||||||
|
- `/mail-queue` handler: card shows the fixture policy's human strings, not a
|
||||||
|
live `postconf`.
|
||||||
|
- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those
|
||||||
|
strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)).
|
||||||
|
- [templates_test.go](../../internal/web/view/templates_test.go): pass the new
|
||||||
|
fields if rendering `mail_queue` requires them.
|
||||||
|
|
||||||
|
`go test` / `go vet` on the touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `/mail-queue` states this Postfix's first retry, backoff cap, and queue
|
||||||
|
lifetime.
|
||||||
|
- A `deferred` / `bounced` delivery page uses the same intervals.
|
||||||
|
- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart
|
||||||
|
changes what the panel prints.
|
||||||
|
- Guide and architecture describe the snapshot; CHANGELOG has an Added entry.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
|
||||||
|
operator had overridden them — mitigate with the muted note on the card.
|
||||||
|
- Inventing a max-attempt count would be false; the copy must stay time-based.
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Plan: web-split (splitting `internal/web`)
|
|
||||||
|
|
||||||
**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`)
|
|
||||||
**Version:** `1.x`; an internal refactor, it does not force a break on its own.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
`internal/web` is the project's largest package: ~50 files (templates and
|
|
||||||
static assets included), ~25 `.go` files and ~4000 lines of Go, with the
|
|
||||||
handlers for every panel section, sessions, security headers, origin checking,
|
|
||||||
form validation and template rendering all sitting in one flat namespace.
|
|
||||||
|
|
||||||
The candidates to split out are `web/handlers` and `web/auth`, or a cut along
|
|
||||||
the panel's own domains.
|
|
||||||
|
|
||||||
## Why now
|
|
||||||
|
|
||||||
At its current size the flat package reads fine: the file names
|
|
||||||
(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
|
|
||||||
directories would, and splitting would force exporting what is package-private
|
|
||||||
today — widening the internal API for cosmetics.
|
|
||||||
|
|
||||||
It starts to pay off once the package grows: **domain-admin** and
|
|
||||||
**inbound-relay** both add code to it — the role brings authorisation into
|
|
||||||
every handler, the inbound relay brings its own pages and handlers for inbound
|
|
||||||
domains. The refactor is cheaper before that growth than after it.
|
|
||||||
|
|
||||||
## Recommended order
|
|
||||||
|
|
||||||
**web-split → domain-admin → inbound-relay** (see the
|
|
||||||
[roadmap](../roadmap.md)).
|
|
||||||
|
|
||||||
1. **web-split** — lay down the package structure (including a place for
|
|
||||||
`web/auth`) while there are no cross-cutting edits from the role and no new
|
|
||||||
inbound handlers.
|
|
||||||
2. **domain-admin** — authorisation in every handler builds on a package layout
|
|
||||||
already chosen.
|
|
||||||
3. **inbound-relay** — a new vertical slice; easier to add to an already split
|
|
||||||
package than to refactor alongside the two features before it.
|
|
||||||
|
|
||||||
The order is a recommendation, not a blocker.
|
|
||||||
|
|
||||||
## Chosen scheme
|
|
||||||
|
|
||||||
**Horizontal split into four packages** (decided at implementation):
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/web/ # Config, Server, New, Handler — composition root; security.go
|
|
||||||
internal/web/view/ # embed templates/static, render/renderFragment, staticHandler
|
|
||||||
internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser
|
|
||||||
internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle)
|
|
||||||
internal/web/handlers/ # all authenticated page handlers (handlers_*.go)
|
|
||||||
```
|
|
||||||
|
|
||||||
`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported
|
|
||||||
beyond what the composition root needs.
|
|
||||||
|
|
||||||
## Done when
|
|
||||||
|
|
||||||
The package is split along the scheme above. After the split: `build`/`vet`/`test`
|
|
||||||
green, the panel's behaviour unchanged.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Splitting too early — a superfluous internal API and churn with nothing to
|
|
||||||
show for it;
|
|
||||||
- leaving it until after the growth — a harder refactor, tangled up with the
|
|
||||||
features.
|
|
||||||
+12
-9
@@ -47,17 +47,19 @@ 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
|
||||||
|
below)
|
||||||
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
||||||
- 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
|
||||||
|
|
||||||
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
The **domain-admin** role ships in the current line (global administrator plus
|
||||||
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
domain administrators with assigned domains). The optional **inbound relay** is
|
||||||
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
the main agreed **1.x+** extension still on the
|
||||||
implementation. Items still marked *candidate* in the roadmap require explicit
|
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
|
||||||
approval before coding.
|
remains possible pending implementation. Items marked *candidate* in the
|
||||||
|
roadmap require explicit approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,5 +104,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.
|
||||||
|
|||||||
+98
-53
@@ -15,7 +15,9 @@ planned but unlisted.
|
|||||||
|
|
||||||
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
**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
|
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||||||
an explicit break.
|
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
|
**Process:** [development.md](development.md). The history of closed phases is
|
||||||
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
@@ -26,21 +28,46 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
|
|
||||||
| ID | Topic | Status | Plan |
|
| ID | Topic | Status | Plan |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
|
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) |
|
||||||
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — |
|
| contributing | `CONTRIBUTING.md` | candidate | — |
|
||||||
| visual-style | Обновление визуального стиля | candidate | — |
|
|
||||||
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||||
|
| panel-docs | In-panel operator documentation | candidate | — |
|
||||||
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — |
|
||||||
|
|
||||||
**Recommended order** (not binding): **domain-admin →
|
**Recommended order** (not binding): **queue-retries** is a small panel item
|
||||||
inbound-relay** — role-wide authorisation first, then the inbound relay vertical
|
that can land first or in parallel; the next feature is **inbound-relay**.
|
||||||
slice. Deviating is allowed; there are no hard phases here.
|
The 2026-08-13 full-tree review follow-ups (send-log authorization,
|
||||||
|
fail-closed paths, docs, GUI, tests, licence) are closed — history in
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need
|
||||||
|
explicit agreement before they join the queue.
|
||||||
|
|
||||||
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
the checklist in its linked plan.
|
the checklist in its linked plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## queue-retries
|
||||||
|
|
||||||
|
**Goal:** show on Mail queue and on a delivery's history how this Postfix
|
||||||
|
retries deferred mail — first delay, backoff cap, queue lifetime — reading
|
||||||
|
the effective config (`postconf -h`) once at panel start so a manual
|
||||||
|
override is visible.
|
||||||
|
|
||||||
|
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no
|
||||||
|
panel knobs for queue lifetime, no schema change. Domain administrators see
|
||||||
|
the intervals on `/deliveries/{id}` (they cannot open Mail queue).
|
||||||
|
|
||||||
|
**Done when:** see the criteria in
|
||||||
|
[plans/queue-retries.md](plans/queue-retries.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** `postconf` unavailable outside the container
|
||||||
|
(fallback + muted note). Copy must stay time-based — Postfix has no max
|
||||||
|
attempt count.
|
||||||
|
**Version:** patch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## inbound-relay
|
## inbound-relay
|
||||||
|
|
||||||
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
||||||
@@ -57,33 +84,11 @@ engine stays outside the image, only the attachment point is provided.
|
|||||||
|
|
||||||
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
||||||
a wider attack surface (port 25 accepting mail).
|
a wider attack surface (port 25 accepting mail).
|
||||||
**Order:** recommended after [domain-admin](plans/domain-admin.md).
|
|
||||||
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
||||||
implementation lands.
|
implementation lands.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## domain-admin
|
|
||||||
|
|
||||||
**Goal:** a role with access to one or several assigned domains (the list is
|
|
||||||
set by the global administrator) — applications, DKIM/DNS, and the send log for
|
|
||||||
each of them; without global operations (adding domains, full backup, the
|
|
||||||
queue, `mail.log`).
|
|
||||||
|
|
||||||
**Boundary:** an extension of v1.0 — [product.md](product.md) fixes a single
|
|
||||||
administrator. Not a second all-powerful admin, but limited access to the
|
|
||||||
assigned domains (one or several).
|
|
||||||
|
|
||||||
**Done when:** see [plans/domain-admin.md](plans/domain-admin.md).
|
|
||||||
|
|
||||||
**Dependencies / risks:** a users table, the role in the session, authorisation
|
|
||||||
in every handler, setup and backup. **Order:** recommended **before**
|
|
||||||
[inbound-relay](plans/inbound-relay.md).
|
|
||||||
**Version:** `1.x` MINOR, given a compatible migration of the current
|
|
||||||
administrator into a global one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## contributing
|
## contributing
|
||||||
|
|
||||||
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
@@ -102,28 +107,6 @@ priority.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## visual-style
|
|
||||||
|
|
||||||
**Goal:** refresh the control panel's visual design — typography, colour tokens,
|
|
||||||
spacing, and component styling — without changing operator workflows or panel
|
|
||||||
behaviour.
|
|
||||||
|
|
||||||
**Boundary:** presentation only (`panel.css`, templates, static assets); no new
|
|
||||||
features. Styling must stay compatible with the panel CSP — rules live in
|
|
||||||
`panel.css`, not inline (see [security.md](security.md) and the stylesheet
|
|
||||||
header).
|
|
||||||
|
|
||||||
**Done when:** the panel reflects an agreed visual direction (starting
|
|
||||||
reference: [assets/selfpost-proof.html](assets/selfpost-proof.html)); light and
|
|
||||||
dark schemes remain supported; readability and contrast are preserved.
|
|
||||||
|
|
||||||
**Dependencies / risks:** CSP constraints on how styles are applied; visual
|
|
||||||
regression across pages; low priority relative to functional work — take up
|
|
||||||
after explicit agreement, independently of the feature roadmap order.
|
|
||||||
**Version:** no bearing on semver.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## dmarc-reports
|
## dmarc-reports
|
||||||
|
|
||||||
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||||
@@ -139,12 +122,74 @@ reports (`ruf=`) out of scope for v1.
|
|||||||
|
|
||||||
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
||||||
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
||||||
storage and retention of parsed summaries; the `admin.dmarc_report_email` and
|
storage and retention of parsed summaries; the `dmarc_report_email` setting
|
||||||
`domains.dmarc_rua` settings added in the DMARC template work must stay the
|
(migration `0005` moved it off the old `admin` table into `settings`) and
|
||||||
source of truth for `rua=` in DNS guidance.
|
`domains.dmarc_rua` added in the DMARC template work must stay the source of
|
||||||
|
truth for `rua=` in DNS guidance.
|
||||||
|
|
||||||
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
||||||
inbound-relay depending on how port 25 acceptance is structured.
|
inbound-relay depending on how port 25 acceptance is structured.
|
||||||
|
|
||||||
**Version:** `1.x` MINOR.
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## panel-docs
|
||||||
|
|
||||||
|
**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:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## schema-squash
|
||||||
|
|
||||||
|
**Goal:** when 2.x is cut, stop shipping the 1.x migration files
|
||||||
|
(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with
|
||||||
|
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data
|
||||||
|
directories no longer create-then-drop the historical `admin` table.
|
||||||
|
|
||||||
|
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots.
|
||||||
|
Do not delete, rename, or reorder those files while MINOR compatibility with
|
||||||
|
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target
|
||||||
|
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing
|
||||||
|
databases. Git history keeps the old files either way; only the embedded set
|
||||||
|
in the 2.x image changes.
|
||||||
|
|
||||||
|
**Upgrade gate (required with the squash):**
|
||||||
|
|
||||||
|
| `user_version` | 2.x behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head |
|
||||||
|
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
|
||||||
|
| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
|
||||||
|
|
||||||
|
Restore remains a separate lock: the backup manifest version must match the
|
||||||
|
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
||||||
|
replace this gate.
|
||||||
|
|
||||||
|
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
||||||
|
it); the gate above is tested; the operator guide says a 2.x image will not
|
||||||
|
open an unfinished 1.x database.
|
||||||
|
|
||||||
|
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
||||||
|
explicit major). Squashing five short files is not a reason to cut 2.x on its
|
||||||
|
own. A missed gate leaves a `user_version = 3` database silently stuck.
|
||||||
|
**Version:** `2.x` major only; not a 1.x item.
|
||||||
|
|
||||||
|
|||||||
+59
-24
@@ -5,7 +5,12 @@ meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
|||||||
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
login in the `saslpasswd2` argv
|
login in the `saslpasswd2` argv
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** —
|
([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. (2) **Accepted risks** —
|
||||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
lost.
|
lost.
|
||||||
|
|
||||||
@@ -34,8 +39,8 @@ The panel is exposed to the internet — the items below are **not optional**.
|
|||||||
- Failed attempts do **not** invalidate the token early (protects setup from
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
being DoS-ed).
|
being DoS-ed).
|
||||||
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
|
- The administrator password is bcrypt in SQLite only; no plaintext and no
|
||||||
and no MD5.
|
MD5.
|
||||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
### Application SASL passwords
|
### Application SASL passwords
|
||||||
@@ -112,19 +117,34 @@ deferred item from the roadmap.
|
|||||||
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
- **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
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
deliberately: the panel is single-user, the administrator picks the browser,
|
deliberately: every panel user (global or domain-admin) is an operator who
|
||||||
and a strict mode would not "protect" such a client, it would simply break the
|
picks their own browser, not an untrusted party the panel needs to defend
|
||||||
panel in it. Tightening is one line in `originAllowed`
|
against, and a strict mode would not "protect" such a client, it would
|
||||||
|
simply break the panel in it. Tightening is one line in `originAllowed`
|
||||||
([internal/web/security.go](../internal/web/security.go)): return `false`
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
instead of `true` in the "neither header present" branch.
|
instead of `true` in the "neither header present" branch.
|
||||||
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
neighbouring-subdomain case but depends on browser behaviour; a token does
|
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
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
revisit is a requirement for protection that holds regardless of the browser.
|
revisit is a requirement for protection that holds regardless of the browser,
|
||||||
A token would not save the panel from XSS inside it either: code executing in
|
or a domain-admin population the global administrator does not fully trust
|
||||||
the panel's origin sends the request itself — against that, `html/template`
|
(see the ADR below). A token would not save the panel from XSS inside it
|
||||||
auto-escaping and CSP do the work, which is why templates must contain no
|
either: code executing in the panel's origin sends the request itself —
|
||||||
inline scripts and no inline styles.
|
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||||
|
templates must contain no inline scripts and no inline styles.
|
||||||
|
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||||
|
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||||
|
`data-confirm` prompt handled entirely in
|
||||||
|
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
|
||||||
|
or blocked the form submits immediately, exactly as it did before the
|
||||||
|
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
|
||||||
|
not an authorization boundary — the same origin check and session/RBAC
|
||||||
|
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||||
|
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
|
||||||
|
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||||
|
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||||
- **Encrypting backups and exports is an option, not the default.** With the
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
operator with nowhere to keep a password would lose the ability to take a
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
@@ -158,24 +178,35 @@ deferred item from the roadmap.
|
|||||||
|
|
||||||
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
**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
|
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 the logged-in
|
page apart from one initiated by a third-party site in a logged-in user's
|
||||||
administrator's browser.
|
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.
|
||||||
|
|
||||||
**Decision.** `originAllowed` in
|
**Decision.** `originAllowed` in
|
||||||
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
request carrying neither header is **allowed through** rather than rejected.
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
There are no session-bound tokens embedded in forms.
|
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.** The panel is single-user (one administrator per instance) —
|
**Why not tokens.** Cross-user CSRF is not the threat model here: a
|
||||||
the threat model does not include cross-user CSRF inside the panel itself, only
|
domain-admin's browser sending a request still needs that domain-admin's own
|
||||||
an external site making the administrator's browser send a request. The origin
|
cookie, so a token would not add a boundary between roles that the
|
||||||
check covers that without touching a single template: a token would need a
|
authorization checks (`Principal.CanAccessDomain`,
|
||||||
hidden field in roughly two dozen forms and synchronisation with every new form,
|
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
|
||||||
and it would still not protect against XSS inside the panel — code executing in
|
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
|
||||||
the panel's origin reads the token and sends the request itself. XSS is handled
|
don't already enforce. The remaining case is an external site making a
|
||||||
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
|
logged-in user's browser send a request, which the origin check covers without
|
||||||
not a CSRF token.
|
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.
|
||||||
|
|
||||||
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
@@ -183,7 +214,11 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
|
|||||||
such a client, at the price of a narrow residual surface.
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
**Revisit if:** a requirement appears for protection that does not depend on
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
browser behaviour, or the panel becomes multi-user.
|
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
|
## How this list grows
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -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,9 +199,9 @@ 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 trusted-IP override (guide § Rate
|
||||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
// limiting). The caller has validated the IPs and numbers (security.md); the
|
||||||
// is needed.
|
// milter 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, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeApp,
|
Scope: store.RateLimitScopeApp,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+23
-16
@@ -6,13 +6,19 @@
|
|||||||
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
|
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
|
||||||
// queue are deliberately excluded (architecture.md § Persistence).
|
// queue are deliberately excluded (architecture.md § Persistence).
|
||||||
//
|
//
|
||||||
// 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 /data bind mount before first start, and the archive already carries
|
||||||
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal
|
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender
|
||||||
// start. The only restore-specific step is CheckRestore, which refuses to boot
|
// map — so the daemons can start on the extracted files alone. The
|
||||||
// if the manifest's version does not match the running binary, so
|
// 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 (
|
||||||
@@ -264,33 +270,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,17 +156,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 +183,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 +201,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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ type Domain struct {
|
|||||||
// compares DNS against the key this server actually signs with. Hostname and
|
// compares DNS against the key this server actually signs with. Hostname and
|
||||||
// ServerIPs identify this server and come from a preceding Server check.
|
// ServerIPs identify this server and come from a preceding Server check.
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Name string
|
Name string
|
||||||
Selector string
|
Selector string
|
||||||
ExpectedDKIM string
|
ExpectedDKIM string
|
||||||
Hostname string
|
Hostname string
|
||||||
ServerIPs []string
|
ServerIPs []string
|
||||||
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
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
|
||||||
|
|||||||
@@ -167,6 +167,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
|
||||||
|
|||||||
@@ -145,13 +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, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeDomain,
|
Scope: store.RateLimitScopeDomain,
|
||||||
RefID: domainID,
|
RefID: domainID,
|
||||||
AllowedIPs: ips,
|
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
|
||||||
MaxMessages: maxMessages,
|
MaxMessages: maxMessages,
|
||||||
WindowSeconds: windowSeconds,
|
WindowSeconds: windowSeconds,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type DomainExport struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
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)
|
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-36
@@ -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 int
|
// 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
|
||||||
|
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, " ")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
|||||||
// 1000 ticks pass, 250 of them idle: 75% busy.
|
// 1000 ticks pass, 250 of them idle: 75% busy.
|
||||||
prev := cpuTimes{total: 10000, idle: 8000}
|
prev := cpuTimes{total: 10000, idle: 8000}
|
||||||
cur := cpuTimes{total: 11000, idle: 8250}
|
cur := cpuTimes{total: 11000, idle: 8250}
|
||||||
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||||
if !got.Measured {
|
if !got.Measured {
|
||||||
t.Fatalf("reading not marked measured: %+v", got)
|
t.Fatalf("reading not marked measured: %+v", got)
|
||||||
}
|
}
|
||||||
@@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
|||||||
if got.Status != StatusOK {
|
if got.Status != StatusOK {
|
||||||
t.Errorf("status = %q, want ok", got.Status)
|
t.Errorf("status = %q, want ok", got.Status)
|
||||||
}
|
}
|
||||||
if got.Cores != 4 || !got.HasLoad {
|
if got.Cores != 4 || got.Threads != 8 || !got.HasLoad {
|
||||||
t.Errorf("cores/load not reported: %+v", got)
|
t.Errorf("cores/threads/load not reported: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Detail != "4 cores · 8 threads" {
|
||||||
|
t.Errorf("detail = %q, want cores and threads only", got.Detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
||||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
|
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, 1, [3]float64{}, true, nil)
|
||||||
if got.Status != StatusWarn {
|
if got.Status != StatusWarn {
|
||||||
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
||||||
}
|
}
|
||||||
|
if got.Detail != "1 cores · 1 threads" {
|
||||||
|
t.Errorf("detail = %q, want topology only (no warn prose)", got.Detail)
|
||||||
|
}
|
||||||
if got.HasLoad {
|
if got.HasLoad {
|
||||||
t.Error("a missing load average should not be reported as zeros")
|
t.Error("a missing load average should not be reported as zeros")
|
||||||
}
|
}
|
||||||
@@ -84,17 +90,44 @@ func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
|
|||||||
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
||||||
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
||||||
} {
|
} {
|
||||||
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
|
got := cpuUsage(c.prev, c.cur, 2, 2, [3]float64{}, c.fresh, nil)
|
||||||
if got.Measured || got.Status != StatusUnknown {
|
if got.Measured || got.Status != StatusUnknown {
|
||||||
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||||
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadCPUTopology(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
const cpuinfo = `processor : 0
|
||||||
|
physical id : 0
|
||||||
|
core id : 0
|
||||||
|
|
||||||
|
processor : 1
|
||||||
|
physical id : 0
|
||||||
|
core id : 0
|
||||||
|
|
||||||
|
processor : 2
|
||||||
|
physical id : 0
|
||||||
|
core id : 1
|
||||||
|
|
||||||
|
processor : 3
|
||||||
|
physical id : 0
|
||||||
|
core id : 1
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cpuinfo"), []byte(cpuinfo), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cores, threads := readCPUTopology(dir)
|
||||||
|
if cores != 2 || threads != 4 {
|
||||||
|
t.Errorf("topology = %d cores / %d threads, want 2/4", cores, threads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadMemory(t *testing.T) {
|
func TestReadMemory(t *testing.T) {
|
||||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
||||||
got := readMemory(dir)
|
got := readMemory(dir)
|
||||||
@@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) {
|
|||||||
if got.TotalBytes != 4*1024*1024*1024 {
|
if got.TotalBytes != 4*1024*1024*1024 {
|
||||||
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
||||||
}
|
}
|
||||||
// 4 GiB total, 2 GiB available to new work: half used, cache included in
|
// 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available).
|
||||||
// what is available.
|
|
||||||
if got.Percent() != 50 {
|
if got.Percent() != 50 {
|
||||||
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
||||||
}
|
}
|
||||||
@@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
|
|||||||
if !first.Memory.Measured {
|
if !first.Memory.Measured {
|
||||||
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
||||||
}
|
}
|
||||||
if first.CPU.Cores != 2 {
|
if first.CPU.Cores != 2 || first.CPU.Threads != 2 {
|
||||||
t.Errorf("cores = %d, want 2", first.CPU.Cores)
|
t.Errorf("cores/threads = %d/%d, want 2/2", first.CPU.Cores, first.CPU.Threads)
|
||||||
|
}
|
||||||
|
if first.CPU.Detail != "2 cores · 2 threads" {
|
||||||
|
t.Errorf("detail = %q, want topology only", first.CPU.Detail)
|
||||||
}
|
}
|
||||||
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
||||||
t.Errorf("load average not read: %+v", first.CPU.Load)
|
t.Errorf("load average not read: %+v", first.CPU.Load)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket {
|
|||||||
default:
|
default:
|
||||||
s.Present = true
|
s.Present = true
|
||||||
s.Status = StatusOK
|
s.Status = StatusOK
|
||||||
s.Detail = "Listening."
|
s.Detail = "Listening"
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import (
|
|||||||
var License []byte
|
var License []byte
|
||||||
|
|
||||||
// SourceURL is where Corresponding Source for the published upstream
|
// SourceURL is where Corresponding Source for the published upstream
|
||||||
// releases lives. Operators who ship a modified version must point their
|
// releases lives. It is injected into the panel footer by view.go.
|
||||||
// users at their own sources instead (NOTICE; AGPL-3.0 §13).
|
// Operators who ship a modified version must change this constant so the
|
||||||
|
// footer points at their own sources (NOTICE; AGPL-3.0 §13).
|
||||||
const SourceURL = "https://github.com/mixeme/selfpost"
|
const SourceURL = "https://github.com/mixeme/selfpost"
|
||||||
|
|
||||||
// CopyrightLine is the short copyright notice shown in the panel footer.
|
// CopyrightLine is the short copyright notice shown in the panel footer.
|
||||||
|
|||||||
@@ -19,3 +19,22 @@ func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
|
|||||||
t.Fatal("embedded LICENSE is empty")
|
t.Fatal("embedded LICENSE is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTICE used to tell modifiers to edit layout.html for the Source URL. The
|
||||||
|
// footer reads legal.SourceURL; a fork that only changed the template would
|
||||||
|
// still advertise the upstream repo.
|
||||||
|
func TestNoticePointsAtSourceURLConstant(t *testing.T) {
|
||||||
|
notice, err := os.ReadFile(filepath.Join("..", "..", "NOTICE"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read NOTICE: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(notice, []byte("internal/legal/legal.go")) {
|
||||||
|
t.Error("NOTICE must tell modifiers to update SourceURL in internal/legal/legal.go")
|
||||||
|
}
|
||||||
|
if bytes.Contains(notice, []byte("layout.html")) {
|
||||||
|
t.Error("NOTICE still tells modifiers to edit layout.html for the Source URL")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(notice, []byte("internal/web/view/static/OFL.txt")) {
|
||||||
|
t.Error("NOTICE must point at the OFL text that travels with the Plex fonts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,29 @@ type inflight struct {
|
|||||||
m map[string]map[*reservation]struct{}
|
m map[string]map[*reservation]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryAdmit decides whether one more message may be sent for key and, if so,
|
||||||
|
// claims its slot — both under a single lock. Counting and reserving in two
|
||||||
|
// separate critical sections would let two SMTP sessions that reach MAIL FROM
|
||||||
|
// at the same moment observe the same total and each take the last free slot,
|
||||||
|
// which is exactly the overshoot the in-flight registry exists to prevent.
|
||||||
|
//
|
||||||
|
// stored is the count the send log already holds for the limit's window and max
|
||||||
|
// is the ceiling; the caller supplies both because only it can query the store.
|
||||||
|
// The returned total is what was measured, for the refusal log line.
|
||||||
|
func (f *inflight) tryAdmit(key string, since time.Time, stored, max int64) (*reservation, int64, bool) {
|
||||||
|
if f == nil {
|
||||||
|
return nil, stored, stored < max // no in-flight accounting (tests)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
total := stored + f.countLocked(key, since)
|
||||||
|
if total >= max {
|
||||||
|
return nil, total, false
|
||||||
|
}
|
||||||
|
return f.reserveLocked(key), total, true
|
||||||
|
}
|
||||||
|
|
||||||
// count returns how many reservations for key were taken within the limit's
|
// count returns how many reservations for key were taken within the limit's
|
||||||
// window (at or after since), pruning any that outlived reservationTTL.
|
// window (at or after since), pruning any that outlived reservationTTL.
|
||||||
func (f *inflight) count(key string, since time.Time) int64 {
|
func (f *inflight) count(key string, since time.Time) int64 {
|
||||||
@@ -45,6 +68,11 @@ func (f *inflight) count(key string, since time.Time) int64 {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
return f.countLocked(key, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countLocked is count's body; the caller holds mu.
|
||||||
|
func (f *inflight) countLocked(key string, since time.Time) int64 {
|
||||||
set := f.m[key]
|
set := f.m[key]
|
||||||
cutoff := time.Now().Add(-reservationTTL)
|
cutoff := time.Now().Add(-reservationTTL)
|
||||||
var n int64
|
var n int64
|
||||||
@@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// reserve claims a slot for key until the message is recorded or released.
|
// reserveLocked is reserve's body; the caller holds mu.
|
||||||
func (f *inflight) reserve(key string) *reservation {
|
func (f *inflight) reserveLocked(key string) *reservation {
|
||||||
if f == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
if f.m == nil {
|
if f.m == nil {
|
||||||
f.m = make(map[string]map[*reservation]struct{})
|
f.m = make(map[string]map[*reservation]struct{})
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-24
@@ -3,6 +3,7 @@ package milter
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ import (
|
|||||||
// swallows recorder errors and still accepts the message. By default it reports
|
// swallows recorder errors and still accepts the message. By default it reports
|
||||||
// no configured rate limit, so the level-2 check is inert unless a test sets
|
// no configured rate limit, so the level-2 check is inert unless a test sets
|
||||||
// limits (see fakeRecorder fields).
|
// limits (see fakeRecorder fields).
|
||||||
|
// mu guards the recorded slices so several sessions may drive one recorder
|
||||||
|
// concurrently, as they do in the real server.
|
||||||
type fakeRecorder struct {
|
type fakeRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
entries []store.SendLogEntry
|
entries []store.SendLogEntry
|
||||||
rejected []store.SendLogEntry
|
rejected []store.SendLogEntry
|
||||||
fail bool
|
fail bool
|
||||||
@@ -27,17 +31,25 @@ type fakeRecorder struct {
|
|||||||
counts map[string]int64
|
counts map[string]int64
|
||||||
lookupErr error
|
lookupErr error
|
||||||
countErr error
|
countErr error
|
||||||
|
|
||||||
|
// onCount, if set, runs inside CountMessages. It lets a test hold every
|
||||||
|
// racing session at the store lookup until they can all proceed together.
|
||||||
|
onCount func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
||||||
if f.fail {
|
if f.fail {
|
||||||
return errors.New("boom")
|
return errors.New("boom")
|
||||||
}
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
f.entries = append(f.entries, e)
|
f.entries = append(f.entries, e)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
|
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
f.rejected = append(f.rejected, e)
|
f.rejected = append(f.rejected, e)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -47,6 +59,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
|
|||||||
return store.RateLimit{}, false, f.lookupErr
|
return store.RateLimit{}, false, f.lookupErr
|
||||||
}
|
}
|
||||||
rl, ok := f.limits[scope+"|"+ref]
|
rl, ok := f.limits[scope+"|"+ref]
|
||||||
|
if ok {
|
||||||
|
rl.Scope = scope
|
||||||
|
}
|
||||||
return rl, ok, nil
|
return rl, ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +69,9 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
|
|||||||
if f.countErr != nil {
|
if f.countErr != nil {
|
||||||
return 0, f.countErr
|
return 0, f.countErr
|
||||||
}
|
}
|
||||||
|
if f.onCount != nil {
|
||||||
|
f.onCount()
|
||||||
|
}
|
||||||
return f.counts[scope+"|"+ref], nil
|
return f.counts[scope+"|"+ref], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +204,14 @@ func TestBracedMacros(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
// limitIP is the client IP rate-limit tests connect from.
|
||||||
// register it so the differentiated check applies.
|
|
||||||
const limitIP = "203.0.113.7"
|
const limitIP = "203.0.113.7"
|
||||||
|
|
||||||
func activeLimit(ips ...string) store.RateLimit {
|
func domainLimit() store.RateLimit {
|
||||||
|
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appLimit(ips ...string) store.RateLimit {
|
||||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +233,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
|||||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||||
}
|
}
|
||||||
@@ -227,7 +248,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||||
}
|
}
|
||||||
@@ -239,7 +260,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||||
}
|
}
|
||||||
@@ -251,25 +272,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
// The sender's IP is not in the domain's registered set, so level-2 does not
|
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
||||||
// apply even though the count is huge (level-1 anvil would still cover it).
|
// are ignored.
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
||||||
|
store.RateLimitScopeApp + "|app1": 2, // under app
|
||||||
|
},
|
||||||
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -288,7 +360,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
|||||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
countErr: errors.New("db down"),
|
countErr: errors.New("db down"),
|
||||||
}
|
}
|
||||||
@@ -300,7 +372,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
|||||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -333,7 +405,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
|
|||||||
func limitedRecorder(count int64) *fakeRecorder {
|
func limitedRecorder(count int64) *fakeRecorder {
|
||||||
return &fakeRecorder{
|
return &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
||||||
}
|
}
|
||||||
@@ -358,6 +430,58 @@ func TestRateLimitCountsInFlightMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The case above is sequential: the second session reads the stored count after
|
||||||
|
// the first has already reserved. Here every session reads it first — the gate
|
||||||
|
// holds them all inside the lookup — which is what concurrent SMTP connections
|
||||||
|
// actually do. However many then race for the single free slot, exactly one may
|
||||||
|
// pass. (TestTryAdmitHandsOutEachSlotOnce is the test that fails when counting
|
||||||
|
// and reserving are not one step; this one pins the session-level behaviour.)
|
||||||
|
func TestRateLimitAdmitsOnlyOneRacingSession(t *testing.T) {
|
||||||
|
rec := limitedRecorder(4) // one below the ceiling of 5
|
||||||
|
fl := &inflight{}
|
||||||
|
gate := make(chan struct{})
|
||||||
|
|
||||||
|
// Every session is held inside the stored-count lookup until all of them
|
||||||
|
// have read it, which is the state the race needs: none of them can see
|
||||||
|
// another's reservation, because none has been taken yet.
|
||||||
|
const racers = 32
|
||||||
|
var atCount, done sync.WaitGroup
|
||||||
|
atCount.Add(racers)
|
||||||
|
go func() { atCount.Wait(); close(gate) }()
|
||||||
|
rec.onCount = func() { atCount.Done(); <-gate }
|
||||||
|
|
||||||
|
responses := make([]milter.Response, racers)
|
||||||
|
for i := range racers {
|
||||||
|
done.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer done.Done()
|
||||||
|
// Connect is skipped so every goroutine starts from the same point;
|
||||||
|
// the client IP is what Connect would have captured.
|
||||||
|
s := &session{rec: rec, flight: fl, clientIP: limitIP}
|
||||||
|
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||||
|
if err != nil {
|
||||||
|
resp = nil // reported as a missing Continue below
|
||||||
|
}
|
||||||
|
responses[i] = resp
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
done.Wait()
|
||||||
|
|
||||||
|
admitted := 0
|
||||||
|
for _, resp := range responses {
|
||||||
|
if resp == milter.RespContinue {
|
||||||
|
admitted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if admitted != 1 {
|
||||||
|
t.Fatalf("%d of %d racing sessions admitted, want exactly 1 — the last slot was handed out twice",
|
||||||
|
admitted, racers)
|
||||||
|
}
|
||||||
|
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 1 {
|
||||||
|
t.Fatalf("in-flight reservations = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Once the message is recorded the stored count sees it, so its reservation
|
// Once the message is recorded the stored count sees it, so its reservation
|
||||||
// must be given back — otherwise it would be counted twice and the ceiling
|
// must be given back — otherwise it would be counted twice and the ceiling
|
||||||
// would drift closed.
|
// would drift closed.
|
||||||
@@ -395,17 +519,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A refused message must not leave the slots it claimed for the limits checked
|
// A trusted app at its ceiling refuses without touching the domain counter;
|
||||||
// before the one that tripped, or every refusal would tighten the ceiling.
|
// no domain reservation should linger after the refusal.
|
||||||
func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
counts: map[string]int64{
|
||||||
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
|
store.RateLimitScopeDomain + "|example.com": 0,
|
||||||
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
|
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
fl := &inflight{}
|
fl := &inflight{}
|
||||||
@@ -415,6 +539,49 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
|||||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
||||||
}
|
}
|
||||||
|
if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("app reservation left behind after refusal: %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ceiling is handed out exactly max times however the sessions interleave.
|
||||||
|
// Counting and reserving in two critical sections passes the sequential tests
|
||||||
|
// above and still overshoots here, because between one session's count and its
|
||||||
|
// reservation any number of others can pass the same check.
|
||||||
|
func TestTryAdmitHandsOutEachSlotOnce(t *testing.T) {
|
||||||
|
const (
|
||||||
|
max = 500
|
||||||
|
workers = 8
|
||||||
|
)
|
||||||
|
fl := &inflight{}
|
||||||
|
since := time.Now().Add(-time.Hour)
|
||||||
|
start := make(chan struct{})
|
||||||
|
admitted := make([]int, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
for {
|
||||||
|
_, _, ok := fl.tryAdmit("domain|example.com", since, 0, max)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
admitted[i]++
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
for _, n := range admitted {
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
if total != max {
|
||||||
|
t.Fatalf("admitted %d messages under a ceiling of %d", total, max)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The in-flight count only covers the limit's own window: a reservation older
|
// The in-flight count only covers the limit's own window: a reservation older
|
||||||
@@ -422,7 +589,10 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
|||||||
// counted against a window it no longer belongs to.
|
// counted against a window it no longer belongs to.
|
||||||
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
|
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
|
||||||
fl := &inflight{}
|
fl := &inflight{}
|
||||||
r := fl.reserve("domain|example.com")
|
r, _, ok := fl.tryAdmit("domain|example.com", time.Now().Add(-time.Hour), 0, 1)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("tryAdmit refused the first message under a ceiling of 1")
|
||||||
|
}
|
||||||
r.at = time.Now().Add(-time.Minute)
|
r.at = time.Now().Add(-time.Minute)
|
||||||
|
|
||||||
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
|
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// overLimit reports whether the message currently being received should be
|
// overLimit reports whether the message currently being received should be
|
||||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
||||||
// checks the domain-level and application-level limits in turn; either being
|
//
|
||||||
// exceeded is enough to refuse.
|
// Trusted application IPs (app limit active and client IP listed) use only the
|
||||||
|
// app ceiling and skip the domain check. Everyone else is under the domain
|
||||||
|
// ceiling when one is configured; otherwise only level 1 applies.
|
||||||
//
|
//
|
||||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||||
@@ -26,46 +28,49 @@ func (s *session) overLimit() bool {
|
|||||||
if s.clientIP == "" {
|
if s.clientIP == "" {
|
||||||
return false // no client IP to key on; level-2 does not apply
|
return false // no client IP to key on; level-2 does not apply
|
||||||
}
|
}
|
||||||
checks := []struct{ scope, ref string }{
|
|
||||||
{store.RateLimitScopeDomain, domainOf(s.from)},
|
if s.login != "" {
|
||||||
{store.RateLimitScopeApp, s.login},
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||||
}
|
|
||||||
var taken []*reservation
|
|
||||||
for _, c := range checks {
|
|
||||||
if c.ref == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
||||||
continue
|
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
|
||||||
|
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
||||||
}
|
}
|
||||||
// No limit configured, an inert draft, or a client IP outside the
|
|
||||||
// registered set: the differentiated limit does not apply here.
|
|
||||||
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
|
||||||
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := c.scope + "|" + c.ref
|
|
||||||
n += s.flight.count(key, since)
|
|
||||||
if n >= int64(rl.MaxMessages) {
|
|
||||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
|
||||||
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
|
||||||
// The message is refused, so the slots claimed for the limits
|
|
||||||
// checked before this one must not stay claimed.
|
|
||||||
for _, r := range taken {
|
|
||||||
s.flight.release(r)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
taken = append(taken, s.flight.reserve(key))
|
|
||||||
}
|
}
|
||||||
s.reserved = append(s.reserved, taken...)
|
|
||||||
|
domain := domainOf(s.from)
|
||||||
|
if domain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !ok || !rl.Active() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.enforceLimit(store.RateLimitScopeDomain, domain, rl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceLimit counts recent messages for scope/ref and refuses when at or
|
||||||
|
// above the ceiling. The stored count and the in-flight slots are weighed and
|
||||||
|
// the admitted message's own slot is taken in one atomic step (tryAdmit), so
|
||||||
|
// two sessions racing at MAIL FROM cannot both claim the last free slot.
|
||||||
|
func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool {
|
||||||
|
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||||
|
stored, err := s.rec.CountMessages(scope, ref, since)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r, n, ok := s.flight.tryAdmit(scope+"|"+ref, since, stored, int64(rl.MaxMessages))
|
||||||
|
if !ok {
|
||||||
|
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||||
|
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.reserved = append(s.reserved, r)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ func (p *Postfix) Reload() error {
|
|||||||
return p.reload()
|
return p.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
|
||||||
|
// that cannot reach supervisord use this to verify file regeneration alone.
|
||||||
|
func (p *Postfix) SetReloadHook(fn func() error) {
|
||||||
|
if fn != nil {
|
||||||
|
p.reload = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
|
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
|
||||||
// sorted for deterministic output and the logins under each key are sorted and
|
// sorted for deterministic output and the logins under each key are sorted and
|
||||||
// de-duplicated. Every address and login is re-checked for injection safety
|
// de-duplicated. Every address and login is re-checked for injection safety
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDomainsForUser returns domains assigned to userID with application counts,
|
||||||
|
// ordered by name.
|
||||||
|
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
|
FROM domains d
|
||||||
|
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
ORDER BY d.name`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list domains for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Domain
|
||||||
|
for rows.Next() {
|
||||||
|
d, err := scanDomain(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// GetDomain returns a single domain (with its application count) by id, or
|
// GetDomain returns a single domain (with its application count) by id, or
|
||||||
// ErrDomainNotFound.
|
// ErrDomainNotFound.
|
||||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||||
|
|||||||
@@ -18,36 +18,40 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
||||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
// a message ceiling over a sliding window, attached to a domain or an
|
||||||
// window, attached to a domain or an application. It is enforced in the
|
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
|
||||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
// architecture.md § Mail path) is the IP backstop that always applies even
|
||||||
// IP backstop that always applies even when this is absent or the milter is
|
// when this is absent or the milter is down.
|
||||||
// down.
|
|
||||||
//
|
//
|
||||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
// Domain limits apply to every client IP once max and window are set. Application
|
||||||
// is only enforced when it is Active(): the design deliberately allows an
|
// limits additionally require AllowedIPs: those trusted addresses get the app
|
||||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
// ceiling (above the domain) and skip the domain check; other IPs stay under
|
||||||
// which case only level 1 protects them (guide § Rate limiting).
|
// the domain limit or level 1 alone (guide § Rate limiting).
|
||||||
type RateLimit struct {
|
type RateLimit struct {
|
||||||
Scope string
|
Scope string
|
||||||
RefID int64
|
RefID int64
|
||||||
AllowedIPs []string // canonical client IPs this limit applies to
|
AllowedIPs []string // trusted client IPs for an application override
|
||||||
MaxMessages int
|
MaxMessages int
|
||||||
WindowSeconds int
|
WindowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active reports whether the limit is fully configured and should be enforced.
|
// Active reports whether the limit is fully configured and should be enforced.
|
||||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
// Domain: max and window only. Application: also needs at least one trusted IP
|
||||||
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
|
// (the privilege that raises the ceiling above the domain).
|
||||||
// known sender.
|
|
||||||
func (r RateLimit) Active() bool {
|
func (r RateLimit) Active() bool {
|
||||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Scope == RateLimitScopeApp {
|
||||||
|
return len(r.AllowedIPs) > 0
|
||||||
|
}
|
||||||
|
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
// AllowsIP reports whether ip is one of the application's trusted client IPs.
|
||||||
// comparison parses both sides so equivalent textual forms of the same address
|
// Used only for application overrides; domain limits do not consult this list.
|
||||||
// match; a client IP outside the list means the differentiated limit does not
|
// Equivalent textual forms of the same address match.
|
||||||
// apply to it (level 1 still does).
|
|
||||||
func (r RateLimit) AllowsIP(ip string) bool {
|
func (r RateLimit) AllowsIP(ip string) bool {
|
||||||
c := net.ParseIP(ip)
|
c := net.ParseIP(ip)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||||
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
// by leaving the corresponding field zero, which makes the limit inert via
|
||||||
// limit inert via Active().
|
// Active() until max and window are both set.
|
||||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||||
var (
|
var (
|
||||||
ips sql.NullString
|
ips sql.NullString
|
||||||
|
|||||||
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
|||||||
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||||
inactive := []RateLimit{
|
inactive := []RateLimit{
|
||||||
{},
|
{},
|
||||||
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||||
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||||
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
|
||||||
|
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||||
}
|
}
|
||||||
for i, rl := range inactive {
|
for i, rl := range inactive {
|
||||||
if rl.Active() {
|
if rl.Active() {
|
||||||
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
|
||||||
if !active.Active() {
|
if !domainActive.Active() {
|
||||||
t.Fatalf("should be active: %+v", active)
|
t.Fatalf("domain without IPs should be active: %+v", domainActive)
|
||||||
}
|
}
|
||||||
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
appActive := RateLimit{
|
||||||
|
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
|
||||||
|
MaxMessages: 5, WindowSeconds: 60,
|
||||||
|
}
|
||||||
|
if !appActive.Active() {
|
||||||
|
t.Fatalf("should be active: %+v", appActive)
|
||||||
|
}
|
||||||
|
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
|
||||||
t.Fatalf("registered IPs should match")
|
t.Fatalf("registered IPs should match")
|
||||||
}
|
}
|
||||||
// Equivalent textual form of the IPv6 address must still match.
|
// Equivalent textual form of the IPv6 address must still match.
|
||||||
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||||
t.Fatalf("expanded IPv6 form should match")
|
t.Fatalf("expanded IPv6 form should match")
|
||||||
}
|
}
|
||||||
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
|
||||||
t.Fatalf("unregistered/invalid IPs must not match")
|
t.Fatalf("unregistered/invalid IPs must not match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
|
// SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of
|
||||||
// application login. An empty field matches everything.
|
// narrowing, and they behave in opposite ways on purpose.
|
||||||
|
//
|
||||||
|
// Domain and AppLogin are the operator's own filters, chosen in the UI: an
|
||||||
|
// empty field matches everything.
|
||||||
|
//
|
||||||
|
// Domains and AllDomains are the authorization scope, which no query parameter
|
||||||
|
// may widen. Domains is the exhaustive set of domain names the caller is
|
||||||
|
// entitled to read, applied as an IN constraint; AllDomains lifts that
|
||||||
|
// restriction and is the only way to read the whole journal. A zero-valued
|
||||||
|
// filter therefore matches *no* rows: a caller that forgets to state a scope
|
||||||
|
// gets an empty log rather than every tenant's mail, which is the failure mode
|
||||||
|
// this struct exists to make impossible.
|
||||||
type SendLogFilter struct {
|
type SendLogFilter struct {
|
||||||
Domain string
|
Domain string
|
||||||
AppLogin string
|
AppLogin string
|
||||||
|
Domains []string
|
||||||
|
AllDomains bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
||||||
// monitoring screen's server-side pagination (product.md's send-log view).
|
// monitoring screen's server-side pagination (product.md's send-log view).
|
||||||
|
// The filter's authorization scope is mandatory: see SendLogFilter.
|
||||||
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
||||||
where, args := sendLogWhere(filter)
|
where, args := sendLogWhere(filter)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
|
|||||||
func sendLogWhere(f SendLogFilter) (string, []any) {
|
func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||||
var clauses []string
|
var clauses []string
|
||||||
var args []any
|
var args []any
|
||||||
|
if !f.AllDomains {
|
||||||
|
// No scope is not "no restriction": a domain administrator whose last
|
||||||
|
// assignment was deleted owns nothing in the journal and must see
|
||||||
|
// nothing, and the same clause catches a caller that never set a scope.
|
||||||
|
if len(f.Domains) == 0 {
|
||||||
|
return " WHERE 1 = 0", nil
|
||||||
|
}
|
||||||
|
marks := make([]string, len(f.Domains))
|
||||||
|
for i, name := range f.Domains {
|
||||||
|
marks[i] = "?"
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
clauses = append(clauses, "domain IN ("+strings.Join(marks, ", ")+")")
|
||||||
|
}
|
||||||
if f.Domain != "" {
|
if f.Domain != "" {
|
||||||
clauses = append(clauses, "domain = ?")
|
clauses = append(clauses, "domain = ?")
|
||||||
args = append(args, f.Domain)
|
args = append(args, f.Domain)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -162,6 +164,61 @@ func TestUpdateStatusNoMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The journal is read by principals who are only entitled to part of it, so
|
||||||
|
// the scope is part of the query rather than something the caller remembers to
|
||||||
|
// apply afterwards. A filter that states no scope is a caller that has not
|
||||||
|
// decided who is asking, and the safe answer to that is nothing.
|
||||||
|
func TestSendLogScopeIsMandatory(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
for _, domain := range []string{"first.example.ru", "second.example.ru"} {
|
||||||
|
if err := st.InsertQueued(SendLogEntry{
|
||||||
|
QueueID: "Q-" + domain, Domain: domain, AppLogin: "app-" + domain,
|
||||||
|
From: "noreply@" + domain, To: "public@example.net", Subject: domain,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertQueued: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
filter SendLogFilter
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
"no scope": {SendLogFilter{}, nil},
|
||||||
|
"empty scope": {SendLogFilter{Domains: []string{}}, nil},
|
||||||
|
"all domains": {SendLogFilter{AllDomains: true}, []string{"first.example.ru", "second.example.ru"}},
|
||||||
|
"one domain": {SendLogFilter{Domains: []string{"first.example.ru"}}, []string{"first.example.ru"}},
|
||||||
|
"two domains": {SendLogFilter{Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru", "second.example.ru"}},
|
||||||
|
"unknown domain": {SendLogFilter{Domains: []string{"third.example.ru"}}, nil},
|
||||||
|
"filter within": {SendLogFilter{Domain: "first.example.ru", Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru"}},
|
||||||
|
"filter outside": {SendLogFilter{Domain: "second.example.ru", Domains: []string{"first.example.ru"}}, nil},
|
||||||
|
"app filter outside": {SendLogFilter{AppLogin: "app-second.example.ru", Domains: []string{"first.example.ru"}}, nil},
|
||||||
|
} {
|
||||||
|
rows, err := st.QuerySendLog(tc.filter, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: QuerySendLog: %v", name, err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, r := range rows {
|
||||||
|
got = append(got, r.Domain)
|
||||||
|
}
|
||||||
|
// Which rows came back is the question here; the page's own order is
|
||||||
|
// newest-first and is tested where it matters.
|
||||||
|
sort.Strings(got)
|
||||||
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Errorf("%s: rows for %v, want %v", name, got, tc.want)
|
||||||
|
}
|
||||||
|
// The count drives pagination, so it has to agree with the page or the
|
||||||
|
// UI advertises pages of rows the reader is not allowed to see.
|
||||||
|
n, err := st.CountSendLog(tc.filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: CountSendLog: %v", name, err)
|
||||||
|
}
|
||||||
|
if int(n) != len(tc.want) {
|
||||||
|
t.Errorf("%s: count %d, want %d", name, n, len(tc.want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteSendLogBefore(t *testing.T) {
|
func TestDeleteSendLogBefore(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package store owns the SelfPost SQLite database: the single file under /data
|
// Package store owns the SelfPost SQLite database: the single file under /data
|
||||||
// that persists the administrator account, sending domains and applications,
|
// that persists panel users (global administrators and domain-admins), sending
|
||||||
// the send log and rate-limit settings (architecture.md § Persistence). It
|
// domains and applications, the send log and rate-limit settings
|
||||||
// exposes typed queries so the rest of the panel never builds SQL by hand.
|
// (architecture.md § Persistence). It exposes typed queries so the rest of the
|
||||||
|
// panel never builds SQL by hand.
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
|||||||
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||||
}
|
}
|
||||||
|
m.setupLimiter.startSweeper()
|
||||||
|
m.loginLimiter.startSweeper()
|
||||||
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/view"
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
)
|
)
|
||||||
@@ -22,6 +26,15 @@ func newTestSessionStore(t *testing.T) *sessionStore {
|
|||||||
return newSessionStore(st, 7*24*time.Hour)
|
return newSessionStore(st, 7*24*time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustCreate(t *testing.T, s *sessionStore, username string) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := s.Create(username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
func mustView(t *testing.T) *view.Engine {
|
func mustView(t *testing.T) *view.Engine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
v, err := view.New("test")
|
v, err := view.New("test")
|
||||||
@@ -86,7 +99,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
|||||||
|
|
||||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||||
m := testModule(t, false)
|
m := testModule(t, false)
|
||||||
token := m.sessions.Create("admin")
|
token := mustCreate(t, m.sessions, "admin")
|
||||||
|
|
||||||
reached := false
|
reached := false
|
||||||
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||||
@@ -107,7 +120,7 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
|||||||
|
|
||||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||||
m := testModule(t, true)
|
m := testModule(t, true)
|
||||||
token := m.sessions.Create("admin")
|
token := mustCreate(t, m.sessions, "admin")
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||||
r.Host = "panel.example.com"
|
r.Host = "panel.example.com"
|
||||||
@@ -132,9 +145,63 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A session that could not be stored must not turn into a cookie: the browser
|
||||||
|
// would look signed in, and every request it made would be bounced to /login
|
||||||
|
// with no explanation. Only the sessions table is broken here, so the request
|
||||||
|
// gets past the user lookup and password check and fails exactly where the
|
||||||
|
// session is written.
|
||||||
|
func TestLoginSetsNoCookieWhenTheSessionCannotBeStored(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct-horse-battery"), 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)
|
||||||
|
}
|
||||||
|
dropSessionsTable(t, path)
|
||||||
|
|
||||||
|
m := New(st, Config{}, mustView(t), "")
|
||||||
|
form := url.Values{"username": {"admin"}, "password": {"correct-horse-battery"}}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.HandleLogin(rec, r)
|
||||||
|
|
||||||
|
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
|
||||||
|
t.Errorf("a session cookie was issued for a session that was never stored: %v", got)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want 500 (the login failed)", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "" {
|
||||||
|
t.Errorf("the browser was sent to %q as if it were signed in", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropSessionsTable breaks session persistence while leaving the rest of the
|
||||||
|
// schema usable. The SQLite driver is registered by internal/store.
|
||||||
|
func dropSessionsTable(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database directly: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec("DROP TABLE sessions"); err != nil {
|
||||||
|
t.Fatalf("drop sessions table: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionRename(t *testing.T) {
|
func TestSessionRename(t *testing.T) {
|
||||||
s := newTestSessionStore(t)
|
s := newTestSessionStore(t)
|
||||||
token := s.Create("admin")
|
token := mustCreate(t, s, "admin")
|
||||||
|
|
||||||
s.Rename(token, "operator")
|
s.Rename(token, "operator")
|
||||||
|
|
||||||
@@ -149,8 +216,8 @@ func TestSessionRename(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionDestroyOthers(t *testing.T) {
|
func TestSessionDestroyOthers(t *testing.T) {
|
||||||
s := newTestSessionStore(t)
|
s := newTestSessionStore(t)
|
||||||
keep := s.Create("admin")
|
keep := mustCreate(t, s, "admin")
|
||||||
other := s.Create("admin")
|
other := mustCreate(t, s, "admin")
|
||||||
|
|
||||||
s.DestroyOthers(keep)
|
s.DestroyOthers(keep)
|
||||||
|
|
||||||
@@ -165,7 +232,7 @@ func TestSessionDestroyOthers(t *testing.T) {
|
|||||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||||
s := newTestSessionStore(t)
|
s := newTestSessionStore(t)
|
||||||
s.idle = -time.Minute
|
s.idle = -time.Minute
|
||||||
token := s.Create("admin")
|
token := mustCreate(t, s, "admin")
|
||||||
|
|
||||||
if _, ok := s.Lookup(token); ok {
|
if _, ok := s.Lookup(token); ok {
|
||||||
t.Fatal("expired session was accepted")
|
t.Fatal("expired session was accepted")
|
||||||
@@ -174,7 +241,7 @@ func TestSessionLookupRejectsExpired(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionTouchThrottled(t *testing.T) {
|
func TestSessionTouchThrottled(t *testing.T) {
|
||||||
s := newTestSessionStore(t)
|
s := newTestSessionStore(t)
|
||||||
token := s.Create("admin")
|
token := mustCreate(t, s, "admin")
|
||||||
|
|
||||||
if s.Touch(token) {
|
if s.Touch(token) {
|
||||||
t.Fatal("touch renewed a session created moments ago")
|
t.Fatal("touch renewed a session created moments ago")
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := m.sessions.Create(user.Username)
|
token, err := m.sessions.Create(user.Username)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: login: create session failed: %v", err)
|
||||||
|
m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
m.setSessionCookie(w, token)
|
m.setSessionCookie(w, token)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testPassword = "correct-horse-battery"
|
||||||
|
|
||||||
|
// moduleWithAdmin returns a panel that has already been through setup, with one
|
||||||
|
// global administrator whose password is testPassword.
|
||||||
|
func moduleWithAdmin(t *testing.T) *Module {
|
||||||
|
t.Helper()
|
||||||
|
m := testModule(t, false)
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := m.store.CreateGlobalUser("admin", string(hash)); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// postLogin submits the sign-in form from remoteAddr (the limiter's key) and
|
||||||
|
// returns what the handler wrote.
|
||||||
|
func postLogin(m *Module, remoteAddr, username, password string) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"username": {username}, "password": {password}}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
r.RemoteAddr = remoteAddr
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.HandleLogin(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCookieValue returns the session token the response issued, or "" if it
|
||||||
|
// issued none.
|
||||||
|
func sessionCookieValue(t *testing.T, m *Module, rec *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == m.sessionCookie() {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSignsInWithTheRightPassword(t *testing.T) {
|
||||||
|
m := moduleWithAdmin(t)
|
||||||
|
|
||||||
|
rec := postLogin(m, "203.0.113.7:5000", "admin", testPassword)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q; want a redirect to /", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
token := sessionCookieValue(t, m, rec)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("no session cookie was issued")
|
||||||
|
}
|
||||||
|
name, ok := m.sessions.Lookup(token)
|
||||||
|
if !ok || name != "admin" {
|
||||||
|
t.Fatalf("the cookie's session resolves to %q, %t; want admin", name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused sign-in must not say which half was wrong: the panel is public, and
|
||||||
|
// distinguishable answers would turn the form into a list of usernames.
|
||||||
|
func TestLoginRefusesBadCredentialsWithoutSayingWhy(t *testing.T) {
|
||||||
|
m := moduleWithAdmin(t)
|
||||||
|
|
||||||
|
bodies := make(map[string]string, 2)
|
||||||
|
for name, creds := range map[string][2]string{
|
||||||
|
"wrong password": {"admin", "not-the-password"},
|
||||||
|
"unknown user": {"nobody", testPassword},
|
||||||
|
} {
|
||||||
|
rec := postLogin(m, "203.0.113.7:5000", creds[0], creds[1])
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: status = %d, want 401", name, rec.Code)
|
||||||
|
}
|
||||||
|
if got := sessionCookieValue(t, m, rec); got != "" {
|
||||||
|
t.Errorf("%s: a session cookie was issued: %q", name, got)
|
||||||
|
}
|
||||||
|
bodies[name] = rec.Body.String()
|
||||||
|
}
|
||||||
|
if bodies["wrong password"] != bodies["unknown user"] {
|
||||||
|
t.Error("the two refusals differ, so the form tells an attacker which usernames exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lockout is what makes online guessing pointless, so it has to hold even
|
||||||
|
// for the request that finally carries the right password — and it has to be
|
||||||
|
// scoped to the address that spent the attempts.
|
||||||
|
func TestLoginLocksOutAfterTooManyAttempts(t *testing.T) {
|
||||||
|
m := moduleWithAdmin(t)
|
||||||
|
const attacker = "203.0.113.7:5000"
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if rec := postLogin(m, attacker, "admin", "guess"); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 401 (still under the limit)", i+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := postLogin(m, attacker, "admin", testPassword)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("status = %d, want 429; the lockout was bypassed by guessing right", rec.Code)
|
||||||
|
}
|
||||||
|
if got := sessionCookieValue(t, m, rec); got != "" {
|
||||||
|
t.Errorf("a locked-out request was signed in: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := postLogin(m, "198.51.100.9:5000", "admin", testPassword); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("another address got %d; one guesser locked out the whole internet", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the first administrator exists there is nothing to sign in as, so the
|
||||||
|
// form is replaced by a pointer to the setup link rather than a password box
|
||||||
|
// that can never succeed.
|
||||||
|
func TestLoginPointsAtSetupBeforeTheFirstAdministrator(t *testing.T) {
|
||||||
|
m := testModule(t, false)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.HandleLogin(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/login", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "No administrator has been created yet") {
|
||||||
|
t.Errorf("the login page does not point at the setup link:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `name="password"`) {
|
||||||
|
t.Errorf("the login page offers a password field with no account to use it:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSetup performs the GET the operator's browser makes when it follows the
|
||||||
|
// one-time link.
|
||||||
|
func getSetup(m *Module, token string) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.HandleSetup(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/setup/"+token, nil))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func postSetup(m *Module, token string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/setup/"+token,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
m.HandleSetup(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupForm(username, password, confirm string) url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"username": {username},
|
||||||
|
"password": {password},
|
||||||
|
"password_confirm": {confirm},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The setup link creates the first global administrator and then stops
|
||||||
|
// existing: the persistent fact is the user row, so the link is dead after a
|
||||||
|
// restart too, not only for the process that served it.
|
||||||
|
func TestSetupCreatesTheFirstAdministratorAndThenCloses(t *testing.T) {
|
||||||
|
m := testModule(t, false)
|
||||||
|
token, ok := m.setup.activeToken()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no setup token on a panel with no users")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := getSetup(m, token); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET the setup link = %d, want the form", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := postSetup(m, token, setupForm("operator", "a-long-enough-password", "a-long-enough-password"))
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
|
||||||
|
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := m.store.GetUserByUsername("operator")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the administrator was not created: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != RoleGlobal {
|
||||||
|
t.Errorf("the first administrator has role %q, want global", u.Role)
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("a-long-enough-password")); err != nil {
|
||||||
|
t.Errorf("the stored hash does not match the password that was set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("the setup link still answers %d after setup completed, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := postSetup(m, token, setupForm("second", "a-long-enough-password", "a-long-enough-password")); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("a second administrator could be created through the setup link (%d)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that is wrong, or one that has aged out and been replaced, is not a
|
||||||
|
// hint that setup exists: both answer 404, the same as any unknown path.
|
||||||
|
func TestSetupRejectsAWrongOrExpiredToken(t *testing.T) {
|
||||||
|
m := testModule(t, false)
|
||||||
|
token, ok := m.setup.activeToken()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no setup token on a panel with no users")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := getSetup(m, token+"x"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("a wrong token answered %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
expireSetupToken(m)
|
||||||
|
|
||||||
|
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("the expired token still opens setup (%d)", rec.Code)
|
||||||
|
}
|
||||||
|
fresh, _ := m.setup.activeToken()
|
||||||
|
if fresh == token {
|
||||||
|
t.Fatal("the expired token was not replaced")
|
||||||
|
}
|
||||||
|
if rec := getSetup(m, fresh); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("the reissued token does not open setup (%d)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first account is the one that can never be locked out of the panel from
|
||||||
|
// outside, so the rules that apply to every other user apply here too — before
|
||||||
|
// anything is written.
|
||||||
|
func TestSetupRejectsCredentialsItWouldNotAcceptLater(t *testing.T) {
|
||||||
|
for name, form := range map[string]url.Values{
|
||||||
|
"username too short": setupForm("op", "a-long-enough-password", "a-long-enough-password"),
|
||||||
|
"username not ASCII": setupForm("оператор", "a-long-enough-password", "a-long-enough-password"),
|
||||||
|
"passwords differ": setupForm("operator", "a-long-enough-password", "a-long-enough-passwerd"),
|
||||||
|
"password too short": setupForm("operator", "short", "short"),
|
||||||
|
"no password at all": setupForm("operator", "", ""),
|
||||||
|
"no username at all": setupForm("", "a-long-enough-password", "a-long-enough-password"),
|
||||||
|
} {
|
||||||
|
m := testModule(t, false)
|
||||||
|
token, _ := m.setup.activeToken()
|
||||||
|
|
||||||
|
rec := postSetup(m, token, form)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: status = %d, want 400", name, rec.Code)
|
||||||
|
}
|
||||||
|
if exists, err := m.store.UserExists(); err != nil || exists {
|
||||||
|
t.Errorf("%s: an administrator was created anyway (err=%v)", name, err)
|
||||||
|
}
|
||||||
|
if rec := getSetup(m, token); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s: the setup link was burned by a rejected form (%d)", name, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is unauthenticated by definition, so the only thing between the token
|
||||||
|
// and an offline guesser is the limiter in front of it.
|
||||||
|
func TestSetupIsRateLimited(t *testing.T) {
|
||||||
|
m := testModule(t, false)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 404 (still under the limit)", i+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("status = %d, want 429 after the eleventh attempt", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireSetupToken ages the current token out, the state the panel reaches when
|
||||||
|
// nobody follows the link within setupTokenTTL.
|
||||||
|
func expireSetupToken(m *Module) {
|
||||||
|
m.setup.mu.Lock()
|
||||||
|
defer m.setup.mu.Unlock()
|
||||||
|
m.setup.expiresAt = m.setup.expiresAt.Add(-2 * setupTokenTTL)
|
||||||
|
}
|
||||||
@@ -44,15 +44,3 @@ func CurrentUser(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireGlobal wraps a handler that only global administrators may reach.
|
|
||||||
func RequireGlobal(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p, ok := CurrentPrincipal(r.Context())
|
|
||||||
if !ok || !p.IsGlobal() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
usernameKey ctxKey = 0
|
usernameKey ctxKey = 0
|
||||||
principalKey ctxKey = 1
|
principalKey ctxKey = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// Role is a panel user's access level.
|
// Role is a panel user's access level.
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultMaxBuckets = 4096
|
||||||
|
|
||||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||||
// setup and login routes (security.md). Keys are client IPs.
|
// setup and login routes (security.md). Keys are client IPs.
|
||||||
type rateLimiter struct {
|
type rateLimiter struct {
|
||||||
max int
|
max int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
|
maxBuckets int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buckets map[string]*rlBucket
|
buckets map[string]*rlBucket
|
||||||
@@ -22,12 +25,25 @@ type rlBucket struct {
|
|||||||
|
|
||||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||||
return &rateLimiter{
|
return &rateLimiter{
|
||||||
max: max,
|
max: max,
|
||||||
window: window,
|
window: window,
|
||||||
buckets: make(map[string]*rlBucket),
|
maxBuckets: defaultMaxBuckets,
|
||||||
|
buckets: make(map[string]*rlBucket),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) startSweeper() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(r.window)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.sweep(time.Now())
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *rateLimiter) Allow(key string) bool {
|
func (r *rateLimiter) Allow(key string) bool {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -35,6 +51,7 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
|
|
||||||
b := r.buckets[key]
|
b := r.buckets[key]
|
||||||
if b == nil || now.After(b.windowEnds) {
|
if b == nil || now.After(b.windowEnds) {
|
||||||
|
r.makeRoom(now)
|
||||||
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
||||||
r.sweep(now)
|
r.sweep(now)
|
||||||
return true
|
return true
|
||||||
@@ -46,6 +63,32 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) makeRoom(now time.Time) {
|
||||||
|
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.sweep(now)
|
||||||
|
for len(r.buckets) >= r.maxBuckets {
|
||||||
|
r.evictOldest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) evictOldest() {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestEnds time.Time
|
||||||
|
first := true
|
||||||
|
for k, b := range r.buckets {
|
||||||
|
if first || b.windowEnds.Before(oldestEnds) {
|
||||||
|
oldestKey = k
|
||||||
|
oldestEnds = b.windowEnds
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey != "" {
|
||||||
|
delete(r.buckets, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *rateLimiter) sweep(now time.Time) {
|
func (r *rateLimiter) sweep(now time.Time) {
|
||||||
for k, b := range r.buckets {
|
for k, b := range r.buckets {
|
||||||
if now.After(b.windowEnds) {
|
if now.After(b.windowEnds) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The limiter is what stands between the public login form and an unlimited
|
||||||
|
// guessing rate (security.md), so the ceiling has to be exact: the configured
|
||||||
|
// number of attempts go through and the next one does not, however often it is
|
||||||
|
// repeated.
|
||||||
|
func TestRateLimiterStopsAtTheCeiling(t *testing.T) {
|
||||||
|
r := newRateLimiter(3, time.Minute)
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
if !r.Allow("203.0.113.7") {
|
||||||
|
t.Fatalf("attempt %d of 3 was refused before the ceiling", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 4; i <= 6; i++ {
|
||||||
|
if r.Allow("203.0.113.7") {
|
||||||
|
t.Fatalf("attempt %d passed after the ceiling of 3", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buckets are per key, so one locked-out address must not lock out the rest of
|
||||||
|
// the internet — a shared counter would turn a single guesser into a denial of
|
||||||
|
// service against every operator.
|
||||||
|
func TestRateLimiterKeepsKeysApart(t *testing.T) {
|
||||||
|
r := newRateLimiter(1, time.Minute)
|
||||||
|
|
||||||
|
if !r.Allow("203.0.113.7") || r.Allow("203.0.113.7") {
|
||||||
|
t.Fatal("the first key did not use up its single attempt")
|
||||||
|
}
|
||||||
|
if !r.Allow("198.51.100.9") {
|
||||||
|
t.Fatal("a second address was refused because another one was locked out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window is fixed, not sliding: once it has elapsed the count starts again
|
||||||
|
// from zero rather than being carried over. Time is moved by ageing the bucket
|
||||||
|
// instead of sleeping, so the test states the boundary rather than approaching
|
||||||
|
// it.
|
||||||
|
func TestRateLimiterReopensAfterTheWindow(t *testing.T) {
|
||||||
|
r := newRateLimiter(2, time.Minute)
|
||||||
|
r.Allow("203.0.113.7")
|
||||||
|
r.Allow("203.0.113.7")
|
||||||
|
if r.Allow("203.0.113.7") {
|
||||||
|
t.Fatal("the ceiling was not reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
expire(r, "203.0.113.7")
|
||||||
|
|
||||||
|
if !r.Allow("203.0.113.7") {
|
||||||
|
t.Fatal("the key is still locked out after its window ended")
|
||||||
|
}
|
||||||
|
if !r.Allow("203.0.113.7") {
|
||||||
|
t.Fatal("the new window did not start from an empty count")
|
||||||
|
}
|
||||||
|
if r.Allow("203.0.113.7") {
|
||||||
|
t.Fatal("the new window allowed more than the ceiling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every address that ever tried to sign in gets a bucket, and the only thing
|
||||||
|
// that removes the finished ones is the sweep on a new window. It runs on the
|
||||||
|
// key that triggered it as well as on the others, so a long-running panel does
|
||||||
|
// not accumulate a bucket per source address for ever.
|
||||||
|
func TestRateLimiterSweepsFinishedBuckets(t *testing.T) {
|
||||||
|
r := newRateLimiter(2, time.Minute)
|
||||||
|
for _, key := range []string{"203.0.113.7", "198.51.100.9"} {
|
||||||
|
r.Allow(key)
|
||||||
|
expire(r, key)
|
||||||
|
}
|
||||||
|
r.Allow("192.0.2.5") // still inside its window
|
||||||
|
|
||||||
|
r.Allow("203.0.113.7") // new window for this key: sweeps the rest
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.buckets["198.51.100.9"]; ok {
|
||||||
|
t.Error("a bucket whose window ended is still held")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"203.0.113.7", "192.0.2.5"} {
|
||||||
|
if _, ok := r.buckets[key]; !ok {
|
||||||
|
t.Errorf("the sweep dropped %s, whose window is still open", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expire moves a key's window into the past, the same state it would reach by
|
||||||
|
// waiting for the window to elapse.
|
||||||
|
func expire(r *rateLimiter, key string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if b := r.buckets[key]; b != nil {
|
||||||
|
b.windowEnds = time.Now().Add(-time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long-running panel can see many unique client addresses. Finished buckets
|
||||||
|
// are swept on every new window, and a hard cap evicts the oldest when the map
|
||||||
|
// would otherwise grow without bound.
|
||||||
|
func TestRateLimiterCapsBucketCount(t *testing.T) {
|
||||||
|
r := newRateLimiter(1, time.Minute)
|
||||||
|
r.maxBuckets = 3
|
||||||
|
|
||||||
|
for i, key := range []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"} {
|
||||||
|
if !r.Allow(key) {
|
||||||
|
t.Fatalf("attempt %d for %s was refused under the cap", i+1, key)
|
||||||
|
}
|
||||||
|
expire(r, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Allow("203.0.113.8") {
|
||||||
|
t.Fatal("a fourth address was refused even though room was made")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buckets) > 3 {
|
||||||
|
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,17 +41,22 @@ func hashToken(token string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create issues a new session for username and returns its token.
|
// Create issues a new session for username and returns its token. It fails
|
||||||
func (s *sessionStore) Create(username string) string {
|
// closed: if the row cannot be written the caller gets an error and must not
|
||||||
|
// hand out a cookie, because a token that is not in the database looks like a
|
||||||
|
// signed-in browser while every request it makes bounces back to /login.
|
||||||
|
func (s *sessionStore) Create(username string) (string, error) {
|
||||||
token := randomToken(32)
|
token := randomToken(32)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||||
logf("panel: session: create failed: %v", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
// Pruning is housekeeping: the new session is already valid, so a failure
|
||||||
|
// here is logged and does not fail the login.
|
||||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||||
logf("panel: session: prune expired failed: %v", err)
|
logf("panel: session: prune expired failed: %v", err)
|
||||||
}
|
}
|
||||||
return token
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup returns the session username for a token if it exists and is
|
// Lookup returns the session username for a token if it exists and is
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ const setupTokenTTL = 10 * time.Minute
|
|||||||
|
|
||||||
// setupManager owns the one-time administrator setup token. The token itself is
|
// setupManager owns the one-time administrator setup token. The token itself is
|
||||||
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
|
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
|
||||||
// persistent "setup complete" fact is the presence of the admin row in the
|
// persistent "setup complete" fact is the presence of any row in the store's
|
||||||
// store, so once that exists the token is gone for good (security.md).
|
// users table (`store.UserExists`), so once the first global administrator is
|
||||||
|
// created the token is gone for good (security.md).
|
||||||
type setupManager struct {
|
type setupManager struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
hostname string
|
hostname string
|
||||||
|
|||||||
@@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
|||||||
if p.IsGlobal() {
|
if p.IsGlobal() {
|
||||||
return h.store.ListDomains()
|
return h.store.ListDomains()
|
||||||
}
|
}
|
||||||
all, err := h.store.ListDomains()
|
return h.store.ListDomainsForUser(p.ID)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var out []store.Domain
|
|
||||||
for _, d := range all {
|
|
||||||
if p.CanAccessDomain(d.ID) {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// route is one entry of the authenticated mux, named the way web.go registers
|
||||||
|
// it so a route added there without a guard is visible as a missing case here.
|
||||||
|
type route struct {
|
||||||
|
method string
|
||||||
|
target string
|
||||||
|
handler func(*Handlers) http.HandlerFunc
|
||||||
|
// pathValues are the {id}-style segments the router would have bound.
|
||||||
|
pathValues map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalOnlyRoutes is every page and action that only a global administrator
|
||||||
|
// may reach: the panel's users, the whole-server backup and domain import, the
|
||||||
|
// machine-wide status and log views, and the domain lifecycle. A domain
|
||||||
|
// administrator is answered 404 rather than 403 so the panel does not confirm
|
||||||
|
// that the page exists (security.md).
|
||||||
|
var globalOnlyRoutes = []route{
|
||||||
|
{"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
|
||||||
|
{"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
||||||
|
{"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
||||||
|
{"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
|
||||||
|
{"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
|
||||||
|
{"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
|
||||||
|
{"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
|
||||||
|
|
||||||
|
{"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
|
||||||
|
{"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
|
||||||
|
{"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
|
||||||
|
|
||||||
|
{"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
|
||||||
|
{"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
|
||||||
|
{"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
|
||||||
|
|
||||||
|
{"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
|
||||||
|
{"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
|
||||||
|
{"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
|
||||||
|
{"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
|
||||||
|
|
||||||
|
{"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
|
||||||
|
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
|
||||||
|
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
|
||||||
|
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain administrator has an account on the panel, so authentication is not
|
||||||
|
// what keeps them off these pages — the per-handler role check is. Each of them
|
||||||
|
// is reached here with a valid session for a principal that owns a domain, the
|
||||||
|
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
|
||||||
|
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
|
||||||
|
h, domains := serverWithTwoDomains(t)
|
||||||
|
p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID)
|
||||||
|
|
||||||
|
for _, rt := range globalOnlyRoutes {
|
||||||
|
rec := call(h, rt, p)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s",
|
||||||
|
rt.method, rt.target, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same 404 covers a request that carries no principal at all: the auth
|
||||||
|
// middleware normally redirects those, but a handler must not depend on
|
||||||
|
// middleware it cannot see for the role it enforces itself.
|
||||||
|
func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
|
||||||
|
h, _ := serverWithTwoDomains(t)
|
||||||
|
|
||||||
|
for _, rt := range globalOnlyRoutes {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||||
|
for k, v := range rt.pathValues {
|
||||||
|
req.SetPathValue(k, v)
|
||||||
|
}
|
||||||
|
rt.handler(h)(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 404s above would also pass if a handler were broken into always returning
|
||||||
|
// one, so at least the two pages that need nothing but the store and the view
|
||||||
|
// have to be shown opening for a global administrator.
|
||||||
|
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
|
||||||
|
h, _ := serverWithTwoDomains(t)
|
||||||
|
|
||||||
|
for _, target := range []string{"/users", "/backup"} {
|
||||||
|
rt := getRoute(t, target)
|
||||||
|
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
|
||||||
|
target, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRoute(t *testing.T, target string) route {
|
||||||
|
t.Helper()
|
||||||
|
for _, rt := range globalOnlyRoutes {
|
||||||
|
if rt.method == http.MethodGet && rt.target == target {
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no GET %s among the global-only routes", target)
|
||||||
|
return route{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||||
|
req = auth.RequestWithPrincipal(req, p)
|
||||||
|
for k, v := range rt.pathValues {
|
||||||
|
req.SetPathValue(k, v)
|
||||||
|
}
|
||||||
|
rt.handler(h)(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestE2ESendLogStatusMarkupDrift renders the real deliveries_rows fragment and
|
||||||
|
// checks whether the e2e gate's HTML scrapers still match it.
|
||||||
|
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
|
||||||
|
h, _ := serverWithDelivery(t)
|
||||||
|
rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows")
|
||||||
|
|
||||||
|
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
|
||||||
|
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
|
||||||
|
containsCell := func(html, needle string) bool {
|
||||||
|
return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCellPattern.FindStringSubmatch(rendered) == nil {
|
||||||
|
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||||
|
}
|
||||||
|
if !containsCell(rendered, "sent") {
|
||||||
|
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snippet(s, needle string) string {
|
||||||
|
i := strings.Index(s, needle)
|
||||||
|
if i < 0 {
|
||||||
|
if len(s) > 200 {
|
||||||
|
return s[:200] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
start := i - 20
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := i + 120
|
||||||
|
if end > len(s) {
|
||||||
|
end = len(s)
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ type Config struct {
|
|||||||
TLSCertFile string
|
TLSCertFile string
|
||||||
OpenDKIMSocket string
|
OpenDKIMSocket string
|
||||||
JournalSocket string
|
JournalSocket string
|
||||||
|
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||||
|
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||||
|
RateLimitMessagesPerIP int
|
||||||
|
RateLimitWindowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers holds dependencies for authenticated panel routes.
|
// Handlers holds dependencies for authenticated panel routes.
|
||||||
|
|||||||
@@ -165,9 +165,11 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
|||||||
data["ExportErr"] = view.ExportErr
|
data["ExportErr"] = view.ExportErr
|
||||||
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||||
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
|
|
||||||
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||||
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||||
|
data["DomainRLMaxNum"] = domainRL.MaxMessages
|
||||||
|
data["L1Messages"] = h.l1Messages()
|
||||||
|
data["L1Window"] = h.l1Window()
|
||||||
h.view.Render(w, status, "domain_detail", data)
|
h.view.Render(w, status, "domain_detail", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat
|
|||||||
// wrong form.
|
// wrong form.
|
||||||
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||||
data := h.pageBase(r)
|
data := h.pageBase(r)
|
||||||
data["Title"] = "SelfPost — backup"
|
data["Title"] = "SelfPost — backup & migration"
|
||||||
data["Active"] = "backup"
|
data["Active"] = "backup"
|
||||||
data["ImportErr"] = importErr
|
data["ImportErr"] = importErr
|
||||||
data["BackupErr"] = backupErr
|
data["BackupErr"] = backupErr
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := h.pageBase(r)
|
data := h.pageBase(r)
|
||||||
data["Title"] = "SelfPost"
|
data["Title"] = "SelfPost — domains"
|
||||||
data["Active"] = "domains"
|
data["Active"] = "domains"
|
||||||
data["Domains"] = h.domainRows(domains)
|
data["Domains"] = h.domainRows(domains)
|
||||||
data["Error"] = formErr
|
data["Error"] = formErr
|
||||||
@@ -124,10 +124,10 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||||
"Title": "SelfPost — delete " + d.Name,
|
"Title": "SelfPost — delete " + d.Name,
|
||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "domains",
|
"Active": "domains",
|
||||||
"Domain": d,
|
"Domain": d,
|
||||||
"IsGlobal": true,
|
"IsGlobal": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
|||||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||||
logRows, logNote := h.deliveryLog(row)
|
logRows, logNote := h.deliveryLog(row)
|
||||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||||
"Title": "SelfPost — delivery",
|
"Title": "SelfPost — delivery",
|
||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "deliveries",
|
"Active": "deliveries",
|
||||||
"IsGlobal": p.IsGlobal(),
|
"IsGlobal": p.IsGlobal(),
|
||||||
"Row": row,
|
"Row": row,
|
||||||
// The status in the panel's own badge vocabulary, so the headline reads
|
// The status in the panel's own badge vocabulary, so the headline reads
|
||||||
// the same way as every other health signal in the panel.
|
// the same way as every other health signal in the panel.
|
||||||
"Level": deliveryLevel(row.Status),
|
"Level": deliveryLevel(row.Status),
|
||||||
@@ -145,6 +145,17 @@ func deliveryLevel(status string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendLogRow is a row of the send log as the table draws it: the stored row
|
||||||
|
// plus the badge level its status maps onto. The level is carried rather than
|
||||||
|
// derived in the template because deliveryLevel is the one place that decides
|
||||||
|
// what a status means — the delivery page already reads it, and a second
|
||||||
|
// mapping written in the template or the stylesheet would be free to drift
|
||||||
|
// from it.
|
||||||
|
type sendLogRow struct {
|
||||||
|
store.SendLogRow
|
||||||
|
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
||||||
|
}
|
||||||
|
|
||||||
// deliveryEvent is one step of a message's history, as the timeline on the
|
// deliveryEvent is one step of a message's history, as the timeline on the
|
||||||
// delivery page draws it. At is zero for the step that has not happened yet —
|
// delivery page draws it. At is zero for the step that has not happened yet —
|
||||||
// the delivery report a queued message is still waiting for.
|
// the delivery report a queued message is still waiting for.
|
||||||
@@ -288,16 +299,22 @@ func deliveriesBackURL(r *http.Request) string {
|
|||||||
// sendLogData reads the domain/app filters and page number off the query
|
// sendLogData reads the domain/app filters and page number off the query
|
||||||
// string, queries the store, and assembles everything the template needs
|
// string, queries the store, and assembles everything the template needs
|
||||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||||
|
//
|
||||||
|
// The invariant this function owes the journal: a principal who is not global
|
||||||
|
// only ever reads rows for the domains assigned to them. That scope is stated
|
||||||
|
// to the store as SendLogFilter.Domains and holds for every number of
|
||||||
|
// assignments, including none — a domain administrator whose last domain was
|
||||||
|
// deleted gets an empty log, not the whole one. The query parameters are
|
||||||
|
// filters *within* that scope and can only narrow it: both are checked against
|
||||||
|
// the assigned domains and their applications before the query runs, because a
|
||||||
|
// dropdown that offers only permitted values is a courtesy to the browser, not
|
||||||
|
// a check on the request.
|
||||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||||
p, ok := h.principal(r)
|
p, ok := h.principal(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("no principal")
|
return nil, errors.New("no principal")
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.SendLogFilter{
|
|
||||||
Domain: q.Get("domain"),
|
|
||||||
AppLogin: q.Get("app"),
|
|
||||||
}
|
|
||||||
|
|
||||||
assigned, err := h.assignedDomains(p)
|
assigned, err := h.assignedDomains(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,29 +322,6 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
allowedNames := domainNameSet(assigned)
|
allowedNames := domainNameSet(assigned)
|
||||||
|
|
||||||
if !p.IsGlobal() {
|
|
||||||
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
|
||||||
filter.Domain = ""
|
|
||||||
}
|
|
||||||
if filter.Domain == "" && len(assigned) == 1 {
|
|
||||||
filter.Domain = assigned[0].Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
page := parsePage(q.Get("p"))
|
|
||||||
|
|
||||||
total, err := h.store.CountSendLog(filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i := range rows {
|
|
||||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
domainNames := make([]string, 0, len(assigned))
|
domainNames := make([]string, 0, len(assigned))
|
||||||
for _, d := range assigned {
|
for _, d := range assigned {
|
||||||
domainNames = append(domainNames, d.Name)
|
domainNames = append(domainNames, d.Name)
|
||||||
@@ -349,8 +343,37 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
sort.Strings(logins)
|
sort.Strings(logins)
|
||||||
|
|
||||||
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
filter := store.SendLogFilter{
|
||||||
filter.AppLogin = ""
|
Domain: q.Get("domain"),
|
||||||
|
AppLogin: q.Get("app"),
|
||||||
|
// A global administrator reads the whole journal, including rows left
|
||||||
|
// behind by a domain that has since been deleted.
|
||||||
|
Domains: domainNames,
|
||||||
|
AllDomains: p.IsGlobal(),
|
||||||
|
}
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
||||||
|
filter.Domain = ""
|
||||||
|
}
|
||||||
|
if filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||||
|
filter.AppLogin = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := parsePage(q.Get("p"))
|
||||||
|
|
||||||
|
total, err := h.store.CountSendLog(filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
view := make([]sendLogRow, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||||
|
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPage := 1
|
lastPage := 1
|
||||||
@@ -358,7 +381,7 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|||||||
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Rows": rows,
|
"Rows": view,
|
||||||
"FilterDomains": domainNames,
|
"FilterDomains": domainNames,
|
||||||
"FilterApps": logins,
|
"FilterApps": logins,
|
||||||
"FilterDomain": filter.Domain,
|
"FilterDomain": filter.Domain,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||||
if err != nil || len(rows) != 1 {
|
if err != nil || len(rows) != 1 {
|
||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,174 @@ func TestDeliveryPageNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A domain administrator reads the journal of the domains assigned to them and
|
||||||
|
// nothing else. The list used to be scoped only when exactly one domain was
|
||||||
|
// assigned, which meant two assignments read as none at all.
|
||||||
|
func TestSendLogScopedToAssignedDomains(t *testing.T) {
|
||||||
|
h, domains := serverWithTwoDomains(t)
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
username string
|
||||||
|
domainIDs []int64
|
||||||
|
want []string
|
||||||
|
unwanted []string
|
||||||
|
}{
|
||||||
|
"global sees both": {
|
||||||
|
"", nil, []string{"First message", "Second message"}, nil,
|
||||||
|
},
|
||||||
|
"one assigned domain": {
|
||||||
|
"one-domain", []int64{domains["first.example.ru"].ID},
|
||||||
|
[]string{"First message"}, []string{"Second message", "second-app"},
|
||||||
|
},
|
||||||
|
"two assigned domains": {
|
||||||
|
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
|
||||||
|
[]string{"First message", "Second message"}, nil,
|
||||||
|
},
|
||||||
|
// Every assigned domain deleted cascades the assignments away. That
|
||||||
|
// leaves a principal entitled to nothing, which is an empty log — the
|
||||||
|
// case that used to hand over the whole journal.
|
||||||
|
"no assigned domains": {
|
||||||
|
"no-domains", nil, []string{"No messages logged yet."},
|
||||||
|
[]string{"First message", "Second message"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
var p auth.Principal
|
||||||
|
if tc.username == "" {
|
||||||
|
p = globalPrincipal
|
||||||
|
} else {
|
||||||
|
p = domainAdmin(t, h.store, tc.username, tc.domainIDs...)
|
||||||
|
}
|
||||||
|
for view, handler := range map[string]http.HandlerFunc{
|
||||||
|
"page": h.HandleDeliveries,
|
||||||
|
"fragment": h.HandleDeliveriesRows,
|
||||||
|
} {
|
||||||
|
out := getBodyAs(t, handler, "/deliveries", p)
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range tc.unwanted {
|
||||||
|
if strings.Contains(out, unwanted) {
|
||||||
|
t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter dropdowns offer only permitted values, so a leak through them can
|
||||||
|
// only come from a hand-written URL — which is exactly why the values are
|
||||||
|
// checked against the principal's own domains and applications rather than
|
||||||
|
// trusted for having been rendered by us.
|
||||||
|
func TestSendLogIgnoresForgedFilters(t *testing.T) {
|
||||||
|
h, domains := serverWithTwoDomains(t)
|
||||||
|
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
|
||||||
|
|
||||||
|
for _, target := range []string{
|
||||||
|
"/deliveries?domain=second.example.ru",
|
||||||
|
"/deliveries?app=second-app",
|
||||||
|
"/deliveries?domain=second.example.ru&app=second-app",
|
||||||
|
} {
|
||||||
|
out := getBodyAs(t, h.HandleDeliveries, target, p)
|
||||||
|
if strings.Contains(out, "Second message") {
|
||||||
|
t.Errorf("GET %s leaks another domain's journal:\n%s", target, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "First message") {
|
||||||
|
t.Errorf("GET %s hid the principal's own journal:\n%s", target, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The detail page has always checked membership; keep it checked, because the
|
||||||
|
// list and the page are two ways to the same row.
|
||||||
|
func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
|
||||||
|
h, domains := serverWithTwoDomains(t)
|
||||||
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
|
||||||
|
req.SetPathValue("id", itoa(rows[0].ID))
|
||||||
|
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
|
||||||
|
h.HandleDelivery(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverWithTwoDomains builds a panel over a store holding two domains, one
|
||||||
|
// application and one delivered message each, so a scoping test can tell "my
|
||||||
|
// rows" from "every row" by reading the page.
|
||||||
|
func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
domains := make(map[string]store.Domain, 2)
|
||||||
|
for _, d := range []struct{ name, app, subject string }{
|
||||||
|
{"first.example.ru", "first-app", "First message"},
|
||||||
|
{"second.example.ru", "second-app", "Second message"},
|
||||||
|
} {
|
||||||
|
dom, err := st.AddDomain(d.name, "mail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add domain %s: %v", d.name, err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil {
|
||||||
|
t.Fatalf("add application %s: %v", d.app, err)
|
||||||
|
}
|
||||||
|
if err := st.InsertQueued(store.SendLogEntry{
|
||||||
|
QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app,
|
||||||
|
From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", d.subject, err)
|
||||||
|
}
|
||||||
|
domains[d.name] = dom
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
||||||
|
|
||||||
|
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
|
||||||
|
t.Helper()
|
||||||
|
const hash = "test-hash"
|
||||||
|
if len(domainIDs) == 0 {
|
||||||
|
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add placeholder domain: %v", err)
|
||||||
|
}
|
||||||
|
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create domain admin %s: %v", username, err)
|
||||||
|
}
|
||||||
|
if err := st.DeleteDomain(placeholder.ID); err != nil {
|
||||||
|
t.Fatalf("delete placeholder domain: %v", err)
|
||||||
|
}
|
||||||
|
domainIDs = nil
|
||||||
|
u, err := st.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get domain admin %s: %v", username, err)
|
||||||
|
}
|
||||||
|
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||||
|
}
|
||||||
|
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create domain admin %s: %v", username, err)
|
||||||
|
}
|
||||||
|
u, err := st.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get domain admin %s: %v", username, err)
|
||||||
|
}
|
||||||
|
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||||
|
}
|
||||||
|
|
||||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||||
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||||
@@ -238,7 +406,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
|||||||
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
|
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
|
||||||
t.Fatalf("update status: %v", err)
|
t.Fatalf("update status: %v", err)
|
||||||
}
|
}
|
||||||
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||||
if err != nil || len(rows) != 1 {
|
if err != nil || len(rows) != 1 {
|
||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
@@ -246,18 +414,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
|||||||
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
// getBody runs one handler over a GET as the global administrator.
|
||||||
// the test on any non-200. The path's {id} is bound by hand because these calls
|
|
||||||
// bypass the router that would otherwise fill it in.
|
|
||||||
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||||
|
t.Helper()
|
||||||
|
return getBodyAs(t, h, target, globalPrincipal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBodyAs runs one handler over a GET as the given principal and returns the
|
||||||
|
// page it wrote, failing the test on any non-200. The path's {id} is bound by
|
||||||
|
// hand because these calls bypass the router that would otherwise fill it in.
|
||||||
|
func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
req = auth.RequestWithPrincipal(req, auth.Principal{
|
req = auth.RequestWithPrincipal(req, p)
|
||||||
ID: 1,
|
|
||||||
Username: "admin",
|
|
||||||
Role: auth.RoleGlobal,
|
|
||||||
})
|
|
||||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||||
req.SetPathValue("id", rest)
|
req.SetPathValue("id", rest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,24 +19,73 @@ type rateLimitInput struct {
|
|||||||
windowSeconds int
|
windowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
func (h *Handlers) l1Messages() int {
|
||||||
|
if h.cfg.RateLimitMessagesPerIP > 0 {
|
||||||
|
return h.cfg.RateLimitMessagesPerIP
|
||||||
|
}
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) l1Window() int {
|
||||||
|
if h.cfg.RateLimitWindowSeconds > 0 {
|
||||||
|
return h.cfg.RateLimitWindowSeconds
|
||||||
|
}
|
||||||
|
return defaultRateLimitWindowSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
}
|
}
|
||||||
if r.PostFormValue("clear") != "" {
|
if r.PostFormValue("clear") != "" {
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{clear: true}, nil
|
||||||
}
|
}
|
||||||
|
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||||
|
if rawMax == "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||||
|
if err != nil || maxMessages <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||||
|
}
|
||||||
|
if maxMessages > l1Max {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||||
|
}
|
||||||
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
|
if err != nil || windowSeconds <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||||
|
}
|
||||||
|
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
|
}
|
||||||
|
if r.PostFormValue("clear") != "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||||
|
if rawMax == "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rateLimitInput{}, err
|
return rateLimitInput{}, err
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
|
||||||
}
|
}
|
||||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||||
if err != nil || maxMessages <= 0 {
|
if err != nil || maxMessages <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||||
}
|
}
|
||||||
|
if maxMessages > l1Max {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||||
|
}
|
||||||
|
if domainActive && maxMessages <= domainMax {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax)
|
||||||
|
}
|
||||||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
if err != nil || windowSeconds <= 0 {
|
if err != nil || windowSeconds <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||||
@@ -77,7 +126,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseDomainRateLimitForm(r, h.l1Messages())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
domainRL, domainOK, err := h.domains.RateLimit(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainActive := domainOK && domainRL.Active()
|
||||||
|
in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDomainRateLimitForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
form := func(vals url.Values) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"50"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100)
|
||||||
|
if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 {
|
||||||
|
t.Fatalf("valid domain = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||||
|
if err != nil || !in.clear {
|
||||||
|
t.Fatalf("empty max should clear: %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"150"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||||
|
t.Fatalf("over L1 want error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAppRateLimitForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
form := func(vals url.Values) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"80"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 {
|
||||||
|
t.Fatalf("valid app override = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"80"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "trusted client IP") {
|
||||||
|
t.Fatalf("missing IPs want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"40"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "greater than the domain") {
|
||||||
|
t.Fatalf("app <= domain want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"150"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 0, false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||||
|
t.Fatalf("over L1 want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No domain limit: any app ceiling ≤ L1 is fine.
|
||||||
|
in, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"50"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 0, false)
|
||||||
|
if err != nil || in.maxMessages != 50 {
|
||||||
|
t.Fatalf("app without domain = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user