diff --git a/.dockerignore b/.dockerignore index e7b8881..d47e802 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,5 +7,4 @@ docs bin *.exe README.md -LICENSE Makefile diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0d3d1..23b2b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,208 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [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 `

` text; the backup page title is `SelfPost — backup & + migration` to reflect domain import as well as full backup. Operator guide and + architecture route tables updated. + +### Fixed + +- image: `mail.log` rotation no longer silently stops when the build context + ships `logrotate-mail.conf` with group/other write bits (common after a + Windows checkout sync). Runtime `COPY --chmod` pins config and script modes + in the Dockerfile; `logrotate-loop.sh` refuses a config logrotate would + ignore. E2e checks mode `644`, forced rotation, and a group-writable context + build. + +## [1.2.2] - 2026-08-12 + +Status page layout after 1.2.1: paired cards in a wide column, denser machine +and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no +migration. + +### Changed + +- panel: **Status** is wide again so paired cards fill the column. Layout: + Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖ + Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS + certificate, and Hostname (and Hostname's trailing detail line); milter + socket paths omitted from the table; queue link reads **View queue**; milter + ok detail is `Listening` without a trailing period and sits in its own + Detail column beside the status badge; CPU detail is only core and thread + counts (no load average); memory detail is `N used of M` without the + «available to new work» clause; network detail lists per-interface totals + only (rates stay in the Usage column). No «On this page» section index — + the paired layout is short enough. Hostname stays in the polled fragment so + the pair survives HTMX refresh. In-panel docs for the removed blurbs filed + as roadmap `panel-docs`. + +### Fixed + +- panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid + shrinks each card to its content and centres it in the track instead of + filling half the row. Side margins are cancelled for `.split > .card` + (Status, Settings, and a delivery's message/history). +- panel: on the user create/edit form, **Assigned domains** checkboxes stacked + the box above the domain name (and stretched it full-width) because the form's + block-label and full-width input rules applied to them. Checkbox rows now use + the shared `label.check` layout; the fieldset has matching spacing. + +## [1.2.1] - 2026-08-11 + +Panel refinements after 1.2.0: navigation icons, status-badge centreing, +drill-down back-link placement, and safeguards for the sole global +administrator. Upgrading is a tag bump; no migration. + +### Fixed + +- panel: status badge text sat low in the box (and below the heading or label + beside it). IBM Plex Mono sits low in its em square; the previous top-heavy + padding made that worse. Bottom padding is now heavier so the word centres + optically. +- panel: the **Users** navigation icon was two full silhouettes with staggered + baselines, so the pair looked lopsided at 16 px. The rear person is now a + right-side crescent (head + shoulder) behind a full front silhouette aligned + with `icon-account`. +- panel: the **Settings** navigation icon was a sun-with-rays (circle plus + spokes), not a gear. It now uses a toothed cog so it matches the other + session icons and the 1.2.0 release note. +- panel: the user create/edit form placed «Back to users» at the bottom of the + card instead of under the heading like the delivery, domain, and domain-delete + pages. A shared `back_link` template now renders every drill-down up-link, and + `TestDrillDownPagesPlaceBackLinkAboveContent` guards its position. + +### Changed + +- panel: the user edit form disables role change and delete for the only global + administrator, with a short note, instead of allowing the action and showing + an error on submit. +- panel: the user create/edit form hides **Assigned domains** when the role is + global administrator, since that role manages every domain anyway. +- panel: **Settings** shows panel credentials and DMARC aggregate reports side + by side for global administrators (the same `.split` layout as a delivery's + message and history). Domain-scoped users keep the single narrow card. + +## [1.2.0] - 2026-08-11 + +The second MINOR after 1.0.0: domain administrators with per-domain scope, a +panel visual refresh on the SelfPost palette, and refinements to navigation and +the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the +single administrator becomes a global user. + +### Added + +- panel: **domain-admin role** — global administrators manage panel users and + assign domains; domain administrators see only their domains (applications, + DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full + backup, mail queue, system log, domain add/delete, and `/reload` stay + global-only. SQLite migration `0005_panel_users` migrates the single + administrator into a global user; sessions and full backup restore carry users + and domain bindings. + +### Fixed + +- Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the + runtime image can copy it into `/usr/share/doc/selfpost/` as AGPL requires. A + clean build failed once the cached layer was invalidated. +- panel: on signed-in pages with a narrow card (**Settings**, the user form) + the heading, flash, card and footer now share one left edge. `.card.narrow` + had overridden only `max-width` while `main > *` still centred siblings on + the 48rem measure, so the card floated 12rem to the right of the title. + `main:has(> .card.narrow) > *` keeps the stack on 24rem without narrowing + the column, so the navigation stays put; login/setup are unchanged. + +### Changed + +- panel: navigation session icons are distinct — **Settings** uses a gear, + the signed-in user line carries the single-user icon, and **Users** a + two-person group mark instead of the same account silhouette for all three. +- panel: `/` redirects domain administrators to `/domains`; global users still + land on `/status`. Navigation hides global-only sections for domain + administrators. +- panel: **Sign out** in the navigation column uses the same type size and + weight as the page entries above it; only the red tint and border mark it as + destructive. +- panel: **visual style** brought in line with the SelfPost mark — brick accent + and warm paper in place of the blue-on-cool-grey defaults, IBM Plex Sans and + IBM Plex Mono served by the panel itself, squarer corners, and column + headings, status badges and small labels set in the mono face. Light and dark + schemes both keep their contrast; no page, control or workflow changed. The + send log stops breaking `Details` and `deferred` across two lines when a row + is tight. Badge padding and line-height are tuned so lowercase labels sit + centred in the box. The three WOFF2 files add ~76 KB to the image and are + served from the panel's own origin, so the Content-Security-Policy is + unchanged (`default-src 'self'`). +- panel: the send log's **status is a badge**, in the same ok/warn/error/unknown + colours the status page and the DNS checks use, instead of the one place in + the panel where a status was bare text. The mapping is the one the delivery + page already applied — `sent` is ok, `deferred` a warning, `bounced` and + `rejected` errors, `queued` unknown because nothing has gone wrong yet. +- docs: [roadmap.md](docs/roadmap.md) and [product.md](docs/product.md) no + longer list domain-admin or visual-style as open work — both ship in this line. + Completed plan files (`domain-admin`, `visual-style`, `web-split`, + `narrow-page-alignment`) are removed; history stays in git and the entries + above. Inbound relay is the main agreed 1.x+ item left on the roadmap. + ## [1.1.0] - 2026-08-10 The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL diff --git a/NOTICE b/NOTICE index a38602b..ca4c34c 100644 --- a/NOTICE +++ b/NOTICE @@ -11,21 +11,28 @@ Corresponding Source for the published releases is at: If you modify SelfPost and let users interact with it over a network, AGPL-3.0 §13 requires you to offer them the Corresponding Source of your modified version. Update the Source link in the panel footer -(internal/web/templates/layout.html) so it points at your sources. +(internal/web/view/templates/layout.html) so it points at your sources. 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 Licence: Zero-Clause BSD (0BSD) 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" Licence: SIL Open Font License 1.1 - The shipped SVGs use outlined paths derived from these fonts; the - font files themselves are not distributed. + https://github.com/IBM/plex + Distributed unmodified as three latin-subset WOFF2 files served by the + control panel: + internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700) + internal/web/view/static/ibm-plex-mono-400.woff2 + internal/web/view/static/ibm-plex-mono-600.woff2 + Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from + the Google Fonts distribution. The wordmarks in the shipped SVG logos + are outlined paths derived from the same fonts. Go module dependencies are listed with their licences in docs/development.md § External libraries. Debian packages in the diff --git a/README.md b/README.md index 7dae4b9..ab632da 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups. - Web panel — domains, applications, deliveries, mail queue, system log, backup - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check -- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits +- 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) - Single Docker image; data in a `./data` bind mount @@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:1.1.0 + ghcr.io/mixeme/selfpost:1.2.5 ``` **Get the setup URL** (pick one): diff --git a/build/Dockerfile b/build/Dockerfile index 0631979..6215816 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -88,20 +88,20 @@ COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup # Licence text shipped with the image (AGPL-3.0 conveyance). The panel also # serves the same text at /license from an embedded copy. -COPY LICENSE NOTICE /usr/share/doc/selfpost/ +COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/ -COPY build/opendkim.conf /etc/opendkim.conf -COPY build/logrotate-mail.conf /etc/logrotate.d/mail -COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh -COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh -COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh -COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh -COPY build/crashexit.py /usr/local/bin/crashexit.py -COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh -COPY build/supervisord.conf /etc/supervisor/supervisord.conf -RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \ - /usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \ - /usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh +# File modes are pinned here so a build context copied from a checkout without +# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config +# that logrotate would silently ignore — see docs/plans/logrotate-mode.md. +COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf +COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail +COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh +COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh +COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh +COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh +COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py +COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf # Published submission ports: 465 (smtps, primary) and 587 (submission, optional) # plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, diff --git a/build/logrotate-loop.sh b/build/logrotate-loop.sh index c934d21..4a16970 100644 --- a/build/logrotate-loop.sh +++ b/build/logrotate-loop.sh @@ -14,10 +14,49 @@ # often than daily — polling merely bounds how late a legitimate rotation runs. set -eu +CONFIG=/etc/logrotate.d/mail 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 - if logrotate /etc/logrotate.d/mail; then + if ! logrotate_config_ok; then + logrotate_config_fatal + fi + if run_logrotate; then : else echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2 diff --git a/build/supervisord.conf b/build/supervisord.conf index 99b3234..fef65fc 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -104,8 +104,9 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept -; in the image). Runs as root so logrotate can read/rotate the log; never exits -; non-zero, so it neither trips the crashexit listener nor needs restarting. +; in the image). Runs as root so logrotate can read/rotate the log. Exits +; non-zero when the config is group/other-writable (logrotate would ignore it +; silently); autorestart surfaces BACKOFF on the Status page. [program:logrotate] command=/usr/local/bin/logrotate-loop.sh priority=400 diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 39bc84e..5c3af6b 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -26,19 +26,21 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { 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, + 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) if err != nil { return err diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 6484cce..d553466 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -63,6 +63,8 @@ type config struct { trustedProxies []*net.IPNet sessionIdleDays int dnsResolvers []string + rateLimitMessagesPerIP int + rateLimitWindowSeconds int // Read-only inputs to the panel's status page: the certificate Postfix // serves and the two milter sockets it connects to. The defaults mirror @@ -117,6 +119,10 @@ func loadConfig() config { // means dnscheck's public defaults; a closed network names its own here. dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")), + // Level-1 anvil defaults match build/postfix-config.sh / guide.md. + rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100), + rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600), + tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d4dd7b6..1209160 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.1.0 + image: ghcr.io/mixeme/selfpost:1.2.5 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/architecture.md b/docs/architecture.md index aba6fe2..315fb2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,11 +81,13 @@ One process, three roles: proxy only. 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** - (`default_action=accept`) so milter failure does not stop mail. The level-2 - count is the stored send-log rows plus the messages this process has admitted - but not yet written (`internal/milter/inflight.go`), so concurrent sessions - cannot each spend the same last slot; a reservation is released at - end-of-message, on ABORT, or after a 10-minute TTL. + (`default_action=accept`) so milter failure does not stop mail. Domain + ceilings apply to every client IP; an application ceiling with trusted IPs + raises the limit for those IPs only and skips the domain check (guide § Rate + limiting). The level-2 count is the stored send-log rows plus the messages + this process has admitted but not yet written (`internal/milter/inflight.go`), + so concurrent sessions cannot each spend the same last slot; a reservation + is released at end-of-message, on ABORT, or after a 10-minute TTL. 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by queue-id. Send-log `queued → sent` transitions depend on this goroutine alone (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). @@ -170,7 +172,8 @@ unless noted. The table below is a summary — HTMX fragment endpoints | `/system-log` | `mail.log` tail | | `/reload` | Reload OpenDKIM + Postfix maps | | `/backup` | Full backup download (page also hosts the import form) | -| `/account` | Admin username/password | +| `/settings` | Admin username/password and DMARC report address | +| `/users`, `/users/*` | Panel user CRUD (global admin only) | 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 diff --git a/docs/development.md b/docs/development.md index e8b51e1..40bc595 100644 --- a/docs/development.md +++ b/docs/development.md @@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`. ```sh 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: @@ -162,8 +162,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 -runtime stage (Debian + mail stack). See [architecture.md](architecture.md) § -Image and processes. +runtime stage (Debian + mail stack). Runtime config and scripts use `COPY +--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. --- diff --git a/docs/guide.md b/docs/guide.md index 5378ee5..499efd8 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -189,7 +189,7 @@ service healthy and will mail be accepted?" - **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel), TLS certificate validity and expiry, milter socket presence, and a short Postfix queue summary. The **Machine** card adds the resource usage of the - host underneath — processor (with the load average), memory and swap, and + host underneath — processor (core and thread counts), memory and swap, and per-interface network throughput and totals — read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear one refresh after the page opens. A fully busy processor or a machine out of @@ -202,7 +202,8 @@ service healthy and will mail be accepted?" shows after manual edits under `/data`. - **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits - (level 2) are configured here. *Export domain* writes a single-domain archive; + (level 2) and trusted-IP application overrides are configured here. + *Export domain* writes a single-domain archive; *Import a domain* on the Backup page reads one back in. - **Deliveries** (`/deliveries`) — searchable send log with server-side filters by domain and application. A row identifies its message and nothing more — @@ -229,7 +230,7 @@ service healthy and will mail be accepted?" - **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. +- **Settings** (`/settings`) — 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 @@ -269,8 +270,10 @@ docker compose exec selfpost cat /data/setup-token ## Rate limiting -SelfPost applies two independent limits; both can refuse a submission, but only -level 2 writes a `rejected` row in the send log. +SelfPost applies two independent layers; both can refuse a submission, but only +level 2 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 (IP backstop)** — always on, configured via `.env`: @@ -278,14 +281,20 @@ level 2 writes a `rejected` row in the send log. - `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit` This is an anvil limit per connecting client IP. It keeps working even if the -journal-milter (level 2) is down. +journal-milter (level 2) is down. There is no per-IP bypass. -**Level 2 (per domain / per application)** — optional, configured in the panel -on each domain's page or on an individual application. You set a message -ceiling, a time window, and optionally restrict the limit to specific client -IPs; an empty IP list means the differentiated limit does not apply. When -exceeded, Postfix returns a 4xx and the refusal is recorded in Deliveries as -`rejected`. +**Level 2 — domain** — optional, on each domain's page. 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. + +When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is +recorded in Deliveries as `rejected`. ## Backup, restore, and moving a single domain @@ -381,7 +390,7 @@ but it can look like an open port in external scans. ## Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. The current pin is `1.1.0`. Intermediate +deliberately never `:latest`. The current pin is `1.2.5`. Intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that image was published. Pinning matters because of the backup version check above: the panel binary's embedded version and the image tag that produced it are the diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md deleted file mode 100644 index 69c50e2..0000000 --- a/docs/plans/domain-admin.md +++ /dev/null @@ -1,72 +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), 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.requireAuth(authed))` wrapper), and the session carries -nothing beyond the fact of being signed in. - -The role grants access to **explicitly assigned domains** (one or several); the -list of domains is set by the **global administrator**. For each domain on that -list: - -- that domain's applications (creation, sender mode, password regeneration, - deletion, its own L2 limit); -- the domain's DKIM/DNS status; -- the send log filtered to the domain — the filter already exists in the log - ([sendLogData](../../internal/web/handlers/handlers_monitor.go)). - -What stays outside the role is what is global by nature: - -- adding and removing domains; -- creating domain-admin users and assigning domains to them; -- `/reload`; -- the full backup (that is all of `/data` including `sasldb2`, i.e. every - domain at once); -- the queue and the `mail.log` tail — those are server-wide and not tied to a - domain. - -## 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.)* - -## 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. diff --git a/docs/plans/inbound-relay.md b/docs/plans/inbound-relay.md index e27311d..6291ebd 100644 --- a/docs/plans/inbound-relay.md +++ b/docs/plans/inbound-relay.md @@ -3,8 +3,9 @@ **Status:** agreed **Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once the implementation lands (do not fix a major in advance). -**Order:** recommended after [web-split](web-split.md) and -[domain-admin](domain-admin.md). +**Order:** recommended next among agreed 1.x+ items (after `internal/web` split +and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md) +`[1.2.0]`). --- diff --git a/docs/plans/logrotate-mode.md b/docs/plans/logrotate-mode.md new file mode 100644 index 0000000..e16ecdd --- /dev/null +++ b/docs/plans/logrotate-mode.md @@ -0,0 +1,28 @@ +# Plan: logrotate-mode (mail.log stops rotating in some images) + +**Status:** done +**Version:** patch; no schema, no configuration surface. + +--- + +## Summary + +`mail.log` stopped rotating when `/etc/logrotate.d/mail` landed in the image +with group/other write permission (e.g. build context from a Windows tar sync). +logrotate ignores such configs but exits 0, so the loop looked healthy while +the log grew without bound. + +## What shipped + +- [`build/Dockerfile`](../../build/Dockerfile): `COPY --chmod` pins config + (`0644`) and script (`0755`) modes so the image no longer depends on checkout + file modes. +- [`build/logrotate-loop.sh`](../../build/logrotate-loop.sh): preflight and + per-iteration checks refuse group/other-writable configs; logrotate stderr + mentioning `Ignoring` is fatal. +- [`test/e2e/logrotate_check.go`](../../test/e2e/logrotate_check.go): asserts + mode `644`, forced `logrotate -f`, and that a group-writable context file + still produces `644` in the image. + +`create 0640 postfix selfpost` in [`build/logrotate-mail.conf`](../../build/logrotate-mail.conf) +is unchanged — required for panel readability after rotation. diff --git a/docs/plans/web-split.md b/docs/plans/web-split.md deleted file mode 100644 index 97c7b00..0000000 --- a/docs/plans/web-split.md +++ /dev/null @@ -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. diff --git a/docs/product.md b/docs/product.md index 823e445..95ce410 100644 --- a/docs/product.md +++ b/docs/product.md @@ -47,17 +47,19 @@ Explicitly excluded to prevent scope creep: - Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes) - Webmail -- Multi-user panel / organisations / roles — one administrator; managing - **multiple sending domains** is in scope (see below) +- Organisations / tenancy beyond global + domain-admin roles; managing + **multiple sending domains** under one global administrator is in scope (see + below) - Inbound antispam/antivirus (rspamd, ClamAV, etc.) - A custom MTA — Postfix is used as-is - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only -Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are -tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets -a 1.x MINOR bump by default; a 2.x major remains possible pending -implementation. Items still marked *candidate* in the roadmap require explicit -approval before coding. +The **domain-admin** role ships in the current line (global administrator plus +domain administrators with assigned domains). The optional **inbound relay** is +the main agreed **1.x+** extension still on the +[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major +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 application** — removes only that app's SASL and map entries. -This is not multi-tenancy (one admin); it is one owner operating several -sending domains with independent application credentials. +This is not multi-tenancy; it is one owner (or a small team with global and +domain-scoped roles) operating several sending domains with independent +application credentials. diff --git a/docs/roadmap.md b/docs/roadmap.md index ed9ada0..3420de8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,15 +26,14 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Plan | |---|---|---|---| -| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | 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) | +| panel-docs | In-panel operator documentation | candidate | — | -**Recommended order** (not binding): **domain-admin → -inbound-relay** — role-wide authorisation first, then the inbound relay vertical -slice. Deviating is allowed; there are no hard phases here. +**Recommended order** (not binding): **inbound-relay** first among agreed +items — it is the largest remaining 1.x+ extension. Candidates need explicit +agreement before they join the queue. After a context reset, pick an item marked `agreed` or `in progress`, then work the checklist in its linked plan. @@ -57,33 +56,11 @@ engine stays outside the image, only the attachment point is provided. **Dependencies / risks:** a finished outbound path; open relay and backscatter; 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 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 **Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run @@ -102,28 +79,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 **Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP, @@ -148,3 +103,27 @@ inbound-relay depending on how port 25 acceptance is structured. **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. + diff --git a/internal/app/service.go b/internal/app/service.go index d9bb886..c4bb392 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) { return s.store.GetRateLimit(store.RateLimitScopeApp, appID) } -// SaveRateLimit stores the application-level rate limit. The caller has validated -// the IPs and numbers (security.md); the milter reads the row live, so no reload -// is needed. +// SaveRateLimit stores the application-level trusted-IP override (guide § Rate +// limiting). The caller has validated the IPs and numbers (security.md); the +// milter reads the row live, so no reload is needed. func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ Scope: store.RateLimitScopeApp, diff --git a/internal/domain/service.go b/internal/domain/service.go index f0bc64c..8e6a684 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -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 -// IPs and numbers (security.md); the milter reads the row live, so no reload is -// needed. +// numbers (security.md); the milter reads the row live, so no reload is needed. +// Domain limits do not use an IP allowlist. func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ Scope: store.RateLimitScopeDomain, RefID: domainID, - AllowedIPs: ips, + AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel MaxMessages: maxMessages, WindowSeconds: windowSeconds, }) diff --git a/internal/health/machine.go b/internal/health/machine.go index 37f5b5b..0346b16 100644 --- a/internal/health/machine.go +++ b/internal/health/machine.go @@ -62,10 +62,14 @@ type CPU struct { // nothing until it is true. Measured bool 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 - // could be read. Unlike BusyPct it needs no previous sample, so it is - // there on the very first page load. + // could be read. Unlike BusyPct it needs no previous sample. It is kept + // for the sampler but is no longer shown on the Status card. Load [3]float64 HasLoad bool Status Status @@ -176,7 +180,14 @@ type netCounters struct { func (m *MachineSampler) Sample() Machine { root := m.root() 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) m.mu.Lock() @@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine { fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow mach := Machine{ - CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr), + CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr), Memory: readMemory(root), Network: networkUsage(prevNet, netNow, window, fresh, netErr), } @@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine { return mach } -// cpuUsage grades the processor over the window. The load average is reported -// alongside it because the two answer different questions — how busy the -// processor was, and how many tasks were waiting for it — and a machine can -// look idle while work queues up behind a slow disk. -func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU { - c := CPU{Cores: cores} +// cpuUsage grades the processor over the window. Detail carries only the +// core and thread counts — load average and busy prose stay out of the Status +// card (see roadmap panel-docs for operator-facing explanation later). +func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU { + c := CPU{Cores: cores, Threads: threads} if !isZeroLoad(load) { 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." return c } + c.Detail = c.contextText() if !fresh || cur.total <= prev.total { c.Status = StatusUnknown - c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText()) return c } @@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er if c.BusyPct >= cpuWarnPct { c.Status = StatusWarn - c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.") } else { c.Status = StatusOK - c.Detail = c.contextText() } return c } -// contextText is the CPU's supporting figures: what the percentage is a -// percentage of, and how deep the run queue is. +// contextText is the CPU detail column: physical cores and logical threads. func (c CPU) contextText() string { - var parts []string - if c.Cores > 0 { - parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores)) + switch { + case c.Cores > 0 && c.Threads > 0: + 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 @@ -296,8 +305,7 @@ func readMemory(root string) Memory { m.SwapUsedBytes = m.SwapTotalBytes - swapFree } - detail := fmt.Sprintf("%s used of %s; %s available to new work.", - humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available)) + detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total)) if m.SwapTotalBytes > 0 { 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 } +// 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 // /proc/loadavg simply has none reported, so the failure is a zero value rather // than an error. @@ -544,14 +620,3 @@ func humanRate(perSec float64) string { } 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, " ") -} diff --git a/internal/health/machine_test.go b/internal/health/machine_test.go index e8e3db6..219871f 100644 --- a/internal/health/machine_test.go +++ b/internal/health/machine_test.go @@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) { // 1000 ticks pass, 250 of them idle: 75% busy. prev := cpuTimes{total: 10000, idle: 8000} cur := cpuTimes{total: 11000, idle: 8250} - got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil) + got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil) if !got.Measured { t.Fatalf("reading not marked measured: %+v", got) } @@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) { if got.Status != StatusOK { t.Errorf("status = %q, want ok", got.Status) } - if got.Cores != 4 || !got.HasLoad { - t.Errorf("cores/load not reported: %+v", got) + if got.Cores != 4 || got.Threads != 8 || !got.HasLoad { + 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) { - 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 { 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 { 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}, {"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 { 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) } } +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) { 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) @@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) { if got.TotalBytes != 4*1024*1024*1024 { t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText()) } - // 4 GiB total, 2 GiB available to new work: half used, cache included in - // what is available. + // 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available). if got.Percent() != 50 { t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText()) } @@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) { if !first.Memory.Measured { t.Error("memory is a level, not a rate: it must be reported on the first sample") } - if first.CPU.Cores != 2 { - t.Errorf("cores = %d, want 2", first.CPU.Cores) + if first.CPU.Cores != 2 || first.CPU.Threads != 2 { + 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 { t.Errorf("load average not read: %+v", first.CPU.Load) diff --git a/internal/health/socket.go b/internal/health/socket.go index 04cfcc0..621ebfd 100644 --- a/internal/health/socket.go +++ b/internal/health/socket.go @@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket { default: s.Present = true s.Status = StatusOK - s.Detail = "Listening." + s.Detail = "Listening" } return s } diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index ac35fa4..d2d2a34 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro return store.RateLimit{}, false, f.lookupErr } rl, ok := f.limits[scope+"|"+ref] + if ok { + rl.Scope = scope + } return rl, ok, nil } @@ -186,11 +189,14 @@ func TestBracedMacros(t *testing.T) { } } -// limitAt is the client IP the rate-limit tests connect from; the limits below -// register it so the differentiated check applies. +// limitIP is the client IP rate-limit tests connect from. 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} } @@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response { func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) { rec := &fakeRecorder{ 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 } @@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) { func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) { rec := &fakeRecorder{ 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 } @@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) { func TestRateLimitAllowsUnderLimit(t *testing.T) { rec := &fakeRecorder{ 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 } @@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) { } } -func TestRateLimitIgnoresUnregisteredIP(t *testing.T) { +func TestRateLimitDomainAppliesToAnyIP(t *testing.T) { rec := &fakeRecorder{ 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}, } - // The sender's IP is not in the domain's registered set, so level-2 does not - // apply even though the count is huge (level-1 anvil would still cover it). + // Domain ceilings apply to every client IP; leftover AllowedIPs on the row + // 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 { - 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) { rec := &fakeRecorder{ - // IP registered but no ceiling/window: an inert draft, must not enforce. limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, + store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } @@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) { func TestRateLimitFailsOpenOnCountError(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), }, countErr: errors.New("db down"), } @@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) { func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) { rec := &fakeRecorder{ 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}, } @@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) ( func limitedRecorder(count int64) *fakeRecorder { return &fakeRecorder{ 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}, } @@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) { } } -// A refused message must not leave the slots it claimed for the limits checked -// before the one that tripped, or every refusal would tighten the ceiling. -func TestRefusalReleasesEarlierReservation(t *testing.T) { +// A trusted app at its ceiling refuses without touching the domain counter; +// no domain reservation should linger after the refusal. +func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP), - store.RateLimitScopeApp + "|app1": activeLimit(limitIP), + store.RateLimitScopeDomain + "|example.com": domainLimit(), + store.RateLimitScopeApp + "|app1": appLimit(limitIP), }, counts: map[string]int64{ - store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room - store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling + store.RateLimitScopeDomain + "|example.com": 0, + store.RateLimitScopeApp + "|app1": 5, // app at ceiling }, } fl := &inflight{} @@ -415,6 +472,9 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) { 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) } + 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 in-flight count only covers the limit's own window: a reservation older diff --git a/internal/milter/ratelimit.go b/internal/milter/ratelimit.go index d7a5645..913304b 100644 --- a/internal/milter/ratelimit.go +++ b/internal/milter/ratelimit.go @@ -8,9 +8,11 @@ import ( ) // overLimit reports whether the message currently being received should be -// refused under a level-2 differentiated limit (guide § Rate limiting). It -// checks the domain-level and application-level limits in turn; either being -// exceeded is enough to refuse. +// refused under a level-2 differentiated limit (guide § Rate limiting). +// +// 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 // limit, is treated as "not over limit" so a malfunction of the level-2 @@ -26,46 +28,48 @@ func (s *session) overLimit() bool { if s.clientIP == "" { return false // no client IP to key on; level-2 does not apply } - checks := []struct{ scope, ref string }{ - {store.RateLimitScopeDomain, domainOf(s.from)}, - {store.RateLimitScopeApp, s.login}, - } - var taken []*reservation - for _, c := range checks { - if c.ref == "" { - continue - } - rl, ok, err := s.rec.RateLimit(c.scope, c.ref) + + if s.login != "" { + rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login) if err != nil { - log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err) - continue + log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err) + } 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. On admit it reserves an in-flight slot on the session. +func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool { + since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second) + n, 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 + } + key := scope + "|" + 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", + scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP) + return true + } + s.reserved = append(s.reserved, s.flight.reserve(key)) return false } diff --git a/internal/store/admin.go b/internal/store/admin.go deleted file mode 100644 index 7b7de03..0000000 --- a/internal/store/admin.go +++ /dev/null @@ -1,85 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet. -var ErrNoAdmin = errors.New("no administrator account") - -// Admin is the single panel administrator (security.md). -type Admin struct { - Username string - PasswordHash string - DMARCReportEmail string // default rua= for all sending domains; empty = none - CreatedAt time.Time -} - -// AdminExists reports whether the administrator account has been created. This -// doubles as the "primary setup complete" flag: once true, the /setup route is -// permanently gone (security.md). -func (s *Store) AdminExists() (bool, error) { - var n int - if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil { - return false, fmt.Errorf("count admin: %w", err) - } - return n > 0, nil -} - -// CreateAdmin inserts the administrator row. It fails if one already exists, -// which — combined with the id=1 constraint — makes admin creation one-shot -// even under a race between two setup submissions. -func (s *Store) CreateAdmin(username, passwordHash string) error { - _, err := s.db.Exec( - "INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)", - username, passwordHash, time.Now().UTC().Format(time.RFC3339), - ) - if err != nil { - return fmt.Errorf("create admin: %w", err) - } - return nil -} - -// UpdateAdmin replaces the administrator's username and password hash. It -// touches only the admin row (id = 1): panel credentials are unrelated to the -// SASL logins applications authenticate with, which live in their own table. -// ErrNoAdmin is returned if setup has not happened yet, so a change can never -// silently create an account. -func (s *Store) UpdateAdmin(username, passwordHash, dmarcReportEmail string) error { - res, err := s.db.Exec( - "UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1", - username, passwordHash, dmarcReportEmail, - ) - if err != nil { - return fmt.Errorf("update admin: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("update admin: %w", err) - } - if n == 0 { - return ErrNoAdmin - } - return nil -} - -// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending. -func (s *Store) GetAdmin() (Admin, error) { - var ( - a Admin - createdAt string - ) - err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1"). - Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt) - if errors.Is(err, sql.ErrNoRows) { - return Admin{}, ErrNoAdmin - } - if err != nil { - return Admin{}, fmt.Errorf("get admin: %w", err) - } - a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - return a, nil -} diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go deleted file mode 100644 index 08056d9..0000000 --- a/internal/store/admin_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package store - -import ( - "errors" - "testing" -) - -func TestUpdateAdmin(t *testing.T) { - st := openTestStore(t) - - if err := st.CreateAdmin("admin", "hash-one"); err != nil { - t.Fatalf("CreateAdmin: %v", err) - } - if err := st.UpdateAdmin("operator", "hash-two", "reports@hub.example"); err != nil { - t.Fatalf("UpdateAdmin: %v", err) - } - - a, err := st.GetAdmin() - if err != nil { - t.Fatalf("GetAdmin: %v", err) - } - if a.DMARCReportEmail != "reports@hub.example" { - t.Fatalf("dmarc email = %q", a.DMARCReportEmail) - } - - if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil { - t.Fatalf("clear dmarc email: %v", err) - } - a, err = st.GetAdmin() - if err != nil { - t.Fatalf("GetAdmin: %v", err) - } - if a.Username != "operator" || a.PasswordHash != "hash-three" { - t.Fatalf("unexpected admin after update: %+v", a) - } - if a.CreatedAt.IsZero() { - t.Fatal("update dropped created_at") - } -} - -// An update before setup must not create the account: only the one-time setup -// flow may do that (security.md). -func TestUpdateAdminWithoutAdmin(t *testing.T) { - st := openTestStore(t) - - if err := st.UpdateAdmin("operator", "hash", ""); !errors.Is(err, ErrNoAdmin) { - t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err) - } - exists, err := st.AdminExists() - if err != nil { - t.Fatalf("AdminExists: %v", err) - } - if exists { - t.Fatal("UpdateAdmin created an administrator") - } -} diff --git a/internal/store/migrations/0005_panel_users.sql b/internal/store/migrations/0005_panel_users.sql new file mode 100644 index 0000000..5e205ec --- /dev/null +++ b/internal/store/migrations/0005_panel_users.sql @@ -0,0 +1,26 @@ +-- Panel users and domain-admin assignments. Migrates the single admin row into +-- a global user; drops the admin table. + +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) +); + +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; + +INSERT OR REPLACE INTO settings (key, value) +SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1; + +DROP TABLE admin; diff --git a/internal/store/ratelimits.go b/internal/store/ratelimits.go index 0b7c186..8af46ea 100644 --- a/internal/store/ratelimits.go +++ b/internal/store/ratelimits.go @@ -18,36 +18,40 @@ const ( ) // 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 -// window, attached to a domain or an application. It is enforced in the -// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the -// IP backstop that always applies even when this is absent or the milter is -// down. +// a message ceiling over a sliding window, attached to a domain or an +// application. It is enforced in the journal-milter; level 1 (Postfix anvil, +// architecture.md § Mail path) is the IP backstop that always applies even +// when this is absent or the milter is down. // -// Both the IP binding and the ceiling are optional in the schema, but a limit -// is only enforced when it is Active(): the design deliberately allows an -// admin to leave the IP binding empty for apps that send from changing IPs, in -// which case only level 1 protects them (guide § Rate limiting). +// Domain limits apply to every client IP once max and window are set. Application +// limits additionally require AllowedIPs: those trusted addresses get the app +// ceiling (above the domain) and skip the domain check; other IPs stay under +// the domain limit or level 1 alone (guide § Rate limiting). type RateLimit struct { Scope string RefID int64 - AllowedIPs []string // canonical client IPs this limit applies to + AllowedIPs []string // trusted client IPs for an application override MaxMessages int WindowSeconds int } // Active reports whether the limit is fully configured and should be enforced. -// A missing IP binding, ceiling or window leaves the differentiated limit -// inert (guide § Rate limiting): the IP binding is what scopes the limit to a -// known sender. +// Domain: max and window only. Application: also needs at least one trusted IP +// (the privilege that raises the ceiling above the domain). 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 -// comparison parses both sides so equivalent textual forms of the same address -// match; a client IP outside the list means the differentiated limit does not -// apply to it (level 1 still does). +// AllowsIP reports whether ip is one of the application's trusted client IPs. +// Used only for application overrides; domain limits do not consult this list. +// Equivalent textual forms of the same address match. func (r RateLimit) AllowsIP(ip string) bool { c := net.ParseIP(ip) 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 -// (an IP-only draft) by leaving the corresponding field zero, which makes the -// limit inert via Active(). +// by leaving the corresponding field zero, which makes the limit inert via +// Active() until max and window are both set. func scanRateLimit(r scanRow) (RateLimit, error) { var ( ips sql.NullString diff --git a/internal/store/ratelimits_test.go b/internal/store/ratelimits_test.go index 359fc08..e922145 100644 --- a/internal/store/ratelimits_test.go +++ b/internal/store/ratelimits_test.go @@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) { func TestRateLimitActiveAndAllowsIP(t *testing.T) { inactive := []RateLimit{ {}, - {AllowedIPs: []string{"203.0.113.1"}}, // no ceiling - {AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window - {MaxMessages: 5, WindowSeconds: 60}, // no IPs + {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling + {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window + {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 { if rl.Active() { 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} - if !active.Active() { - t.Fatalf("should be active: %+v", active) + domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60} + if !domainActive.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") } // 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") } - 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") } } diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..b1b4d42 --- /dev/null +++ b/internal/store/settings.go @@ -0,0 +1,32 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +// GetSetting returns a settings value or empty string when missing. +func (s *Store) GetSetting(key string) (string, error) { + var value string + err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get setting %s: %w", key, err) + } + return value, nil +} + +// SetSetting upserts a settings key. +func (s *Store) SetSetting(key, value string) error { + _, err := s.db.Exec( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + key, value, + ) + if err != nil { + return fmt.Errorf("set setting %s: %w", key, err) + } + return nil +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..33776b1 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,364 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// ErrNoUser is returned when primary setup has not happened yet. +var ErrNoUser = errors.New("no panel user") + +// ErrUserNotFound is returned when a panel user id or username does not exist. +var ErrUserNotFound = errors.New("user not found") + +// ErrUserExists is returned when a username is already taken. +var ErrUserExists = errors.New("username already taken") + +// ErrLastGlobal is returned when deleting or demoting the last global user. +var ErrLastGlobal = errors.New("cannot remove last global administrator") + +// Role identifies a panel user's access level. +type Role string + +const ( + RoleGlobal Role = "global" + RoleDomainAdmin Role = "domain_admin" +) + +// User is a panel login (not an application SASL account). +type User struct { + ID int64 + Username string + PasswordHash string + Role Role + DMARCReportEmail string + CreatedAt time.Time + DomainIDs []int64 +} + +// UserExists reports whether any panel user exists (setup complete). +func (s *Store) UserExists() (bool, error) { + var n int + if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n); err != nil { + return false, fmt.Errorf("count users: %w", err) + } + return n > 0, nil +} + +// CreateGlobalUser inserts the first global user during setup. +func (s *Store) CreateGlobalUser(username, passwordHash string) error { + exists, err := s.UserExists() + if err != nil { + return err + } + if exists { + return fmt.Errorf("create global user: users already exist") + } + now := time.Now().UTC().Format(time.RFC3339) + _, err = s.db.Exec( + "INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", + username, passwordHash, RoleGlobal, now, + ) + if err != nil { + return fmt.Errorf("create global user: %w", err) + } + return nil +} + +// GetUserByUsername returns a user with domain assignments loaded. +func (s *Store) GetUserByUsername(username string) (User, error) { + var ( + u User + createdAt string + ) + err := s.db.QueryRow( + "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?", + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrUserNotFound + } + if err != nil { + return User{}, fmt.Errorf("get user by username: %w", err) + } + u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + u.DomainIDs, err = s.listUserDomainIDs(u.ID) + if err != nil { + return User{}, err + } + return u, nil +} + +// GetUser returns a user by id with domain assignments. +func (s *Store) GetUser(id int64) (User, error) { + var ( + u User + createdAt string + ) + err := s.db.QueryRow( + "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?", + id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrUserNotFound + } + if err != nil { + return User{}, fmt.Errorf("get user: %w", err) + } + u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + u.DomainIDs, err = s.listUserDomainIDs(u.ID) + if err != nil { + return User{}, err + } + return u, nil +} + +// ListUsers returns every panel user without domain ids. +func (s *Store) ListUsers() ([]User, error) { + rows, err := s.db.Query( + "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id", + ) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + var users []User + for rows.Next() { + var ( + u User + createdAt string + ) + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil { + return nil, fmt.Errorf("list users scan: %w", err) + } + u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + users = append(users, u) + } + return users, rows.Err() +} + +// UserRow is a user plus assigned domain names for the management list. +type UserRow struct { + User User + DomainNames []string +} + +// ListUserRows returns users with assigned domain names for the management UI. +func (s *Store) ListUserRows() ([]UserRow, error) { + users, err := s.ListUsers() + if err != nil { + return nil, err + } + rows := make([]UserRow, len(users)) + for i, u := range users { + rows[i].User = u + if u.Role == RoleGlobal { + continue + } + names, err := s.listUserDomainNames(u.ID) + if err != nil { + return nil, err + } + rows[i].DomainNames = names + } + return rows, nil +} + +// CountGlobalUsers returns how many global-role users exist. +func (s *Store) CountGlobalUsers() (int, error) { + var n int + if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = ?", RoleGlobal).Scan(&n); err != nil { + return 0, fmt.Errorf("count global users: %w", err) + } + return n, nil +} + +// CreateUser inserts a panel user and optional domain assignments. +func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) { + if role == RoleDomainAdmin && len(domainIDs) == 0 { + return 0, fmt.Errorf("create user: domain_admin requires domains") + } + now := time.Now().UTC().Format(time.RFC3339) + res, err := s.db.Exec( + "INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", + username, passwordHash, role, now, + ) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrUserExists + } + return 0, fmt.Errorf("create user: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("create user id: %w", err) + } + if role == RoleDomainAdmin { + if err := s.setUserDomains(id, domainIDs); err != nil { + return 0, err + } + } + return id, nil +} + +// UpdateUser replaces username, password hash, and DMARC email for a user. +func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error { + u, err := s.GetUser(id) + if err != nil { + return err + } + res, err := s.db.Exec( + "UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?", + username, passwordHash, dmarcReportEmail, id, + ) + if err != nil { + if isUniqueViolation(err) { + return ErrUserExists + } + return fmt.Errorf("update user: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update user: %w", err) + } + if n == 0 { + return ErrUserNotFound + } + if u.Role == RoleGlobal { + if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil { + return err + } + } + return nil +} + +// SetUserRole updates a user's role. +func (s *Store) SetUserRole(userID int64, role Role) error { + res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID) + if err != nil { + return fmt.Errorf("set user role: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("set user role: %w", err) + } + if n == 0 { + return ErrUserNotFound + } + return nil +} + +// ClearUserDomains removes all domain assignments for a user. +func (s *Store) ClearUserDomains(userID int64) error { + _, err := s.db.Exec("DELETE FROM user_domains WHERE user_id = ?", userID) + if err != nil { + return fmt.Errorf("clear user domains: %w", err) + } + return nil +} + +// SetUserDomains replaces domain assignments for a domain_admin user. +func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error { + u, err := s.GetUser(userID) + if err != nil { + return err + } + if u.Role != RoleDomainAdmin { + return fmt.Errorf("set user domains: user is not domain_admin") + } + if len(domainIDs) == 0 { + return fmt.Errorf("set user domains: at least one domain required") + } + return s.setUserDomains(userID, domainIDs) +} + +// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global user. +func (s *Store) DeleteUser(id int64) error { + u, err := s.GetUser(id) + if err != nil { + return err + } + if u.Role == RoleGlobal { + n, err := s.CountGlobalUsers() + if err != nil { + return err + } + if n <= 1 { + return ErrLastGlobal + } + } + res, err := s.db.Exec("DELETE FROM users WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + if n == 0 { + return ErrUserNotFound + } + return nil +} + +// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode. +func (s *Store) GlobalDMARCReportEmail() (string, error) { + return s.GetSetting("dmarc_report_email") +} + +func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) { + rows, err := s.db.Query("SELECT domain_id FROM user_domains WHERE user_id = ? ORDER BY domain_id", userID) + if err != nil { + return nil, fmt.Errorf("list user domains: %w", err) + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("list user domains scan: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *Store) listUserDomainNames(userID int64) ([]string, error) { + rows, err := s.db.Query( + "SELECT d.name FROM user_domains ud JOIN domains d ON d.id = ud.domain_id WHERE ud.user_id = ? ORDER BY d.name", + userID, + ) + if err != nil { + return nil, fmt.Errorf("list user domain names: %w", err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("list user domain names scan: %w", err) + } + names = append(names, name) + } + return names, rows.Err() +} + +func (s *Store) setUserDomains(userID int64, domainIDs []int64) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("set user domains begin: %w", err) + } + if _, err := tx.Exec("DELETE FROM user_domains WHERE user_id = ?", userID); err != nil { + tx.Rollback() + return fmt.Errorf("set user domains clear: %w", err) + } + for _, did := range domainIDs { + if _, err := tx.Exec("INSERT INTO user_domains (user_id, domain_id) VALUES (?, ?)", userID, did); err != nil { + tx.Rollback() + return fmt.Errorf("set user domains insert: %w", err) + } + } + return tx.Commit() +} diff --git a/internal/store/users_test.go b/internal/store/users_test.go new file mode 100644 index 0000000..2d743fd --- /dev/null +++ b/internal/store/users_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "errors" + "testing" +) + +func TestUpdateUser(t *testing.T) { + st := openTestStore(t) + + if err := st.CreateGlobalUser("admin", "hash-one"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + u, err := st.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if err := st.UpdateUser(u.ID, "operator", "hash-two", "reports@hub.example"); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + + got, err := st.GetUser(u.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if got.DMARCReportEmail != "reports@hub.example" { + t.Fatalf("dmarc email = %q", got.DMARCReportEmail) + } + email, err := st.GlobalDMARCReportEmail() + if err != nil { + t.Fatalf("GlobalDMARCReportEmail: %v", err) + } + if email != "reports@hub.example" { + t.Fatalf("settings dmarc = %q", email) + } + + if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil { + t.Fatalf("clear dmarc email: %v", err) + } + got, err = st.GetUser(u.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if got.Username != "operator" || got.PasswordHash != "hash-three" { + t.Fatalf("unexpected user after update: %+v", got) + } + if got.CreatedAt.IsZero() { + t.Fatal("update dropped created_at") + } +} + +func TestUpdateUserWithoutUser(t *testing.T) { + st := openTestStore(t) + + if err := st.UpdateUser(1, "operator", "hash", ""); !errors.Is(err, ErrUserNotFound) { + t.Fatalf("UpdateUser without user = %v, want ErrUserNotFound", err) + } + exists, err := st.UserExists() + if err != nil { + t.Fatalf("UserExists: %v", err) + } + if exists { + t.Fatal("UpdateUser created a user") + } +} + +func TestCreateDomainAdminUser(t *testing.T) { + st := openTestStore(t) + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + d, err := st.AddDomain("example.com", "s1") + if err != nil { + t.Fatalf("AddDomain: %v", err) + } + id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID}) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + u, err := st.GetUser(id) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if len(u.DomainIDs) != 1 || u.DomainIDs[0] != d.ID { + t.Fatalf("domain ids = %v, want [%d]", u.DomainIDs, d.ID) + } +} + +func TestDeleteLastGlobalUser(t *testing.T) { + st := openTestStore(t) + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + u, err := st.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if err := st.DeleteUser(u.ID); !errors.Is(err, ErrLastGlobal) { + t.Fatalf("DeleteUser = %v, want ErrLastGlobal", err) + } +} diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go index 194014f..0b7e762 100644 --- a/internal/web/auth/handlers.go +++ b/internal/web/auth/handlers.go @@ -61,7 +61,7 @@ func (m *Module) clearSessionCookies(w http.ResponseWriter) { // HandleLogin serves the login form (GET) and authenticates (POST). func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) { - exists, err := m.store.AdminExists() + exists, err := m.store.UserExists() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return @@ -106,22 +106,21 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") - admin, err := m.store.GetAdmin() + user, err := m.store.GetUserByUsername(username) if err != nil { - if !errors.Is(err, store.ErrNoAdmin) { - logf("panel: login: get admin failed: %v", err) + if !errors.Is(err, store.ErrUserNotFound) { + logf("panel: login: get user failed: %v", err) } m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") return } - pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) - if username != admin.Username || pwErr != nil { + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") return } - token := m.sessions.Create(admin.Username) + token := m.sessions.Create(user.Username) m.setSessionCookie(w, token) http.Redirect(w, r, "/", http.StatusSeeOther) } @@ -222,13 +221,13 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin return } - if err := m.store.CreateAdmin(username, string(hash)); err != nil { - if exists, _ := m.store.AdminExists(); exists { + if err := m.store.CreateGlobalUser(username, string(hash)); err != nil { + if exists, _ := m.store.UserExists(); exists { m.setup.complete() http.Redirect(w, r, "/login", http.StatusSeeOther) return } - logf("panel: setup: create admin failed: %v", err) + logf("panel: setup: create user failed: %v", err) m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") return } diff --git a/internal/web/auth/middleware.go b/internal/web/auth/middleware.go index 9abdd3a..127373c 100644 --- a/internal/web/auth/middleware.go +++ b/internal/web/auth/middleware.go @@ -1,17 +1,12 @@ package auth import ( - "context" "net/http" ) -type ctxKey int - -const usernameKey ctxKey = 0 - // RequireAuth wraps a handler so only requests with a valid session cookie // reach it; everyone else is redirected to the login page. The authenticated -// username is stashed in the request context for downstream handlers. +// principal is stashed in the request context for downstream handlers. func (m *Module) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := m.sessionToken(r) @@ -27,7 +22,13 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler { if isSessionActivity(r) && m.sessions.Touch(token) { m.setSessionCookie(w, token) } - ctx := context.WithValue(r.Context(), usernameKey, username) + u, err := m.store.GetUserByUsername(username) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + p := principalFromUser(u) + ctx := withPrincipal(r.Context(), p) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -43,3 +44,15 @@ func CurrentUser(r *http.Request) string { } 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) + }) +} diff --git a/internal/web/auth/principal.go b/internal/web/auth/principal.go new file mode 100644 index 0000000..534cff5 --- /dev/null +++ b/internal/web/auth/principal.go @@ -0,0 +1,86 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/mixeme/selfpost/internal/store" +) + +type ctxKey int + +const ( + usernameKey ctxKey = 0 + principalKey ctxKey = 1 +) + +// Role is a panel user's access level. +type Role = store.Role + +const ( + RoleGlobal = store.RoleGlobal + RoleDomainAdmin = store.RoleDomainAdmin +) + +// Principal is the authenticated panel user attached to a request. +type Principal struct { + ID int64 + Username string + Role Role + Domains []int64 // assigned domain IDs; empty for global (all domains) +} + +// IsGlobal reports whether the principal has full panel access. +func (p Principal) IsGlobal() bool { + return p.Role == RoleGlobal +} + +// CanAccessDomain reports whether the principal may access a domain id. +func (p Principal) CanAccessDomain(domainID int64) bool { + if p.IsGlobal() { + return true + } + for _, id := range p.Domains { + if id == domainID { + return true + } + } + return false +} + +// CanAccessApp reports whether the principal may access an application. +func (p Principal) CanAccessApp(app store.Application) bool { + return p.CanAccessDomain(app.DomainID) +} + +func principalFromUser(u store.User) Principal { + return Principal{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + Domains: u.DomainIDs, + } +} + +func withPrincipal(ctx context.Context, p Principal) context.Context { + ctx = context.WithValue(ctx, usernameKey, p.Username) + return context.WithValue(ctx, principalKey, p) +} + +// CurrentPrincipal returns the authenticated principal from the request context. +func CurrentPrincipal(ctx context.Context) (Principal, bool) { + if v, ok := ctx.Value(principalKey).(Principal); ok { + return v, true + } + return Principal{}, false +} + +// PrincipalFromRequest returns the authenticated principal from an HTTP request. +func PrincipalFromRequest(r *http.Request) (Principal, bool) { + return CurrentPrincipal(r.Context()) +} + +// RequestWithPrincipal attaches a principal for middleware-equivalent tests. +func RequestWithPrincipal(r *http.Request, p Principal) *http.Request { + return r.WithContext(withPrincipal(r.Context(), p)) +} diff --git a/internal/web/auth/setup.go b/internal/web/auth/setup.go index 8f28880..b560810 100644 --- a/internal/web/auth/setup.go +++ b/internal/web/auth/setup.go @@ -33,7 +33,7 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager } func (m *setupManager) bootstrap() error { - done, err := m.store.AdminExists() + done, err := m.store.UserExists() if err != nil { return err } @@ -48,9 +48,9 @@ func (m *setupManager) bootstrap() error { } func (m *setupManager) activeToken() (string, bool) { - done, err := m.store.AdminExists() + done, err := m.store.UserExists() if err != nil { - logf("panel: setup: admin check failed: %v", err) + logf("panel: setup: user check failed: %v", err) return "", false } if done { diff --git a/internal/web/handlers/authz.go b/internal/web/handlers/authz.go new file mode 100644 index 0000000..5d8690b --- /dev/null +++ b/internal/web/handlers/authz.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "net/http" + + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" +) + +func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) { + return auth.PrincipalFromRequest(r) +} + +func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) { + p, ok := h.principal(r) + if !ok || !p.IsGlobal() { + http.NotFound(w, r) + return auth.Principal{}, false + } + return p, true +} + +func (h *Handlers) pageBase(r *http.Request) map[string]any { + p, _ := h.principal(r) + return map[string]any{ + "User": auth.CurrentUser(r), + "IsGlobal": p.IsGlobal(), + } +} + +func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) { + if p.IsGlobal() { + return h.store.ListDomains() + } + all, err := h.store.ListDomains() + 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 { + m := make(map[string]bool, len(domains)) + for _, d := range domains { + m[d.Name] = true + } + return m +} + +func domainIDSet(p auth.Principal) map[int64]bool { + if p.IsGlobal() { + return nil + } + m := make(map[int64]bool, len(p.Domains)) + for _, id := range p.Domains { + m[id] = true + } + return m +} diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 3e4ec83..bb530d7 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -24,6 +24,10 @@ type Config struct { TLSCertFile string OpenDKIMSocket 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. diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index 1f920bc..dbacd70 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -14,21 +14,22 @@ import ( "golang.org/x/crypto/bcrypt" ) -// HandleAccount serves the administrator's own account settings: the username -// and password chosen during setup are the only panel credentials -// (security.md), and until now they could be changed only by recreating the -// state. Changing them here never touches application SASL logins, which are a -// separate identity system (architecture.md § Mail path). +// HandleAccount serves the signed-in user's account settings. func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - admin, err := h.store.GetAdmin() - if err != nil { - logf("panel: account: get admin failed: %v", err) + p, ok := h.principal(r) + if !ok { http.Error(w, "internal error", http.StatusInternalServerError) return } - h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) + u, err := h.store.GetUser(p.ID) + if err != nil { + logf("panel: account: get user failed: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal()) case http.MethodPost: h.submitAccount(w, r) default: @@ -37,33 +38,32 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { } } -// renderAccount draws the settings form. formUsername and formDMARCEmail -// repopulate fields after a rejected submission; password fields are never -// repopulated. -func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) { +func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) { var reportAuth dnscheck.Result - if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - reportAuth = h.dns.ReportAuth(ctx, hub) - cancel() + if showDMARC && formDMARCEmail != "" { + if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + reportAuth = h.dns.ReportAuth(ctx, hub) + cancel() + } } - h.view.Render(w, status, "account", map[string]any{ - "Title": "SelfPost — settings", - "User": auth.CurrentUser(r), - "Active": "account", - "FormUsername": formUsername, - "FormDMARCEmail": formDMARCEmail, - "ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)), - "ReportAuthExample": dnscheck.ReportAuthExample(), - "ReportAuthDNS": reportAuth, - "ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail), - "Error": formErr, - "Flash": accountFlash(r), - }) + data := h.pageBase(r) + data["Title"] = "SelfPost — settings" + data["Active"] = "settings" + data["FormUsername"] = formUsername + data["FormDMARCEmail"] = formDMARCEmail + data["ShowDMARC"] = showDMARC + data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)) + data["ReportAuthExample"] = dnscheck.ReportAuthExample() + data["ReportAuthDNS"] = reportAuth + data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail) + data["Error"] = formErr + data["Flash"] = accountFlash(r) + data["L1Messages"] = h.l1Messages() + data["L1Window"] = h.l1Window() + h.view.Render(w, status, "settings", data) } -// accountFlash maps a fixed redirect flag to a fixed message, so status text -// after a redirect is never attacker-influenced. func accountFlash(r *http.Request) string { switch r.URL.Query().Get("updated") { case "username": @@ -85,19 +85,28 @@ func accountFlash(r *http.Request) string { } } -// submitAccount applies a username and/or password change. The current password -// is always required, so a stolen session alone cannot lock the administrator -// out of their own panel, and the attempt is throttled on the same limiter as -// the login form so this route cannot be used to brute-force the password past -// that limit (security.md). func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { if !h.auth.AllowLoginAttempt(r) { + p, _ := h.principal(r) h.renderAccount(w, r, http.StatusTooManyRequests, - "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "") + "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal()) return } if err := r.ParseForm(); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "") + p, _ := h.principal(r) + h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal()) + return + } + + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + user, err := h.store.GetUser(p.ID) + if err != nil { + logf("panel: account: get user failed: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -106,73 +115,76 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { password := r.PostFormValue("new_password") confirm := r.PostFormValue("new_password_confirm") dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) - - admin, err := h.store.GetAdmin() - if err != nil { - logf("panel: account: get admin failed: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return + if !p.IsGlobal() { + dmarcEmail = user.DMARCReportEmail } if username == "" { - username = admin.Username + username = user.Username } - if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { - h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil { + h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal()) return } - renaming := username != admin.Username + renaming := username != user.Username if renaming { if err := validate.Username(username); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) return } } - if err := validate.Email(dmarcEmail); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) - return + if p.IsGlobal() { + if err := validate.Email(dmarcEmail); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true) + return + } } - emailChanging := dmarcEmail != admin.DMARCReportEmail + emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail repassword := password != "" || confirm != "" if repassword { if password != confirm { - h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) + h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal()) return } if err := validate.AdminPassword(password); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) return } } if !renaming && !repassword && !emailChanging { h.renderAccount(w, r, http.StatusBadRequest, - "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail) + "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal()) return } - hash := admin.PasswordHash + hash := user.PasswordHash if repassword { newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { logf("panel: account: hashing password failed: %v", err) h.renderAccount(w, r, http.StatusInternalServerError, - "Internal error. Please try again.", username, dmarcEmail) + "Internal error. Please try again.", username, dmarcEmail, p.IsGlobal()) return } hash = string(newHash) } - if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil { - logf("panel: account: update admin failed: %v", err) + if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil { + logf("panel: account: update user failed: %v", err) msg := "Could not save the changes. Please check the logs and try again." - if errors.Is(err, store.ErrNoAdmin) { - msg = "There is no administrator account to update." + if errors.Is(err, store.ErrUserNotFound) { + msg = "There is no user account to update." } - h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) + if errors.Is(err, store.ErrUserExists) { + msg = "That username is already in use." + h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal()) + return + } + h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal()) return } @@ -185,8 +197,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { } } - logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging) - http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) + logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging) + http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) } func updatedFlag(renamed, repassword, emailChanged bool) string { diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index ef243cd..97379a7 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -10,7 +10,6 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/store" - "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/validate" ) @@ -104,13 +103,12 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st // What DNS actually publishes for the domain today, checked against the key // this server signs with. Cached by the checker, so re-rendering the page // after a form post costs nothing. - admin, err := h.store.GetAdmin() + profileEmail, err := h.store.GlobalDMARCReportEmail() if err != nil { - logf("panel: domain %d: get admin: %v", d.ID, err) + logf("panel: domain %d: global dmarc email: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - profileEmail := admin.DMARCReportEmail reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) dns, srv := h.domainDNS(d, record, profileEmail, false) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) @@ -134,52 +132,45 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st dmarcSource = "settings" } - h.view.Render(w, status, "domain_detail", map[string]any{ - "Title": "SelfPost — " + d.Name, - "User": auth.CurrentUser(r), - "Active": "domains", - "Domain": d, - "Record": record, - "DNS": dns, - // SPF and DMARC are the operator's to write — SelfPost cannot generate - // them the way it generates the DKIM record — so the page shows what - // this server expects rather than leaving it to the documentation. The - // same builders phrase the suggestions in the check messages, so the - // page and the checks below it never recommend different records. - "SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), - "DMARCName": dnscheck.DMARCRecordName(d.Name), - "DMARCExample": dnscheck.DMARCExample(reportEmail), - "DMARCSource": dmarcSource, - "ProfileDMARCEmail": profileEmail, - "ResolvedDMARCEmail": reportEmail, - "DMARCRuaMode": dmarcMode, - "DMARCRuaCustom": dmarcCustom, - "ReportAuthName": reportAuthName, - "ReportAuthValue": reportAuthValue, - "NeedsReportAuth": needsReportAuth, - "SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name), - // Client connection settings (the same for every domain on this - // instance): the hostname clients connect to, and whether the optional - // submission listener is enabled in this deployment. - "Hostname": h.cfg.Hostname, - "SubmissionEnabled": h.cfg.SubmissionEnabled, - "Apps": appViews, - "Error": view.FormErr, - "FormLogin": view.FormLogin, - "FormMode": view.FormMode, - "FormAddrs": view.FormAddrs, - "NewCred": view.NewCred, - "Flash": detailFlash(r), - "Wildcard": store.AddressModeWildcard, - "List": store.AddressModeList, - "RateLimitErr": view.RateLimitErr, - "ExportErr": view.ExportErr, - "MinPwLen": validate.MinSecretFilePasswordLen, - "DomainHasRL": domainRLok && domainRL.Active(), - "DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"), - "DomainRLMax": intOrBlank(domainRL.MaxMessages), - "DomainRLWin": windowOrDefault(domainRL.WindowSeconds), - }) + data := h.pageBase(r) + data["Title"] = "SelfPost — " + d.Name + data["Active"] = "domains" + data["Domain"] = d + data["Record"] = record + data["DNS"] = dns + data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs) + data["DMARCName"] = dnscheck.DMARCRecordName(d.Name) + data["DMARCExample"] = dnscheck.DMARCExample(reportEmail) + data["DMARCSource"] = dmarcSource + data["ProfileDMARCEmail"] = profileEmail + data["ResolvedDMARCEmail"] = reportEmail + data["DMARCRuaMode"] = dmarcMode + data["DMARCRuaCustom"] = dmarcCustom + data["ReportAuthName"] = reportAuthName + data["ReportAuthValue"] = reportAuthValue + data["NeedsReportAuth"] = needsReportAuth + data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) + data["Hostname"] = h.cfg.Hostname + data["SubmissionEnabled"] = h.cfg.SubmissionEnabled + data["Apps"] = appViews + data["Error"] = view.FormErr + data["FormLogin"] = view.FormLogin + data["FormMode"] = view.FormMode + data["FormAddrs"] = view.FormAddrs + data["NewCred"] = view.NewCred + data["Flash"] = detailFlash(r) + data["Wildcard"] = store.AddressModeWildcard + data["List"] = store.AddressModeList + data["RateLimitErr"] = view.RateLimitErr + data["ExportErr"] = view.ExportErr + data["MinPwLen"] = validate.MinSecretFilePasswordLen + data["DomainHasRL"] = domainRLok && domainRL.Active() + data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages) + 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) } // domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC @@ -213,13 +204,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request http.Error(w, "internal error", http.StatusInternalServerError) return } - admin, err := h.store.GetAdmin() + profileEmail, err := h.store.GlobalDMARCReportEmail() if err != nil { - logf("panel: domain %d: get admin: %v", d.ID, err) + logf("panel: domain %d: global dmarc email: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.domainDNS(d, record, admin.DMARCReportEmail, true) + h.domainDNS(d, record, profileEmail, true) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) } @@ -387,6 +378,11 @@ func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (st http.Error(w, "internal error", http.StatusInternalServerError) return store.Application{}, false } + p, ok := h.principal(r) + if !ok || !p.CanAccessApp(a) { + http.NotFound(w, r) + return store.Application{}, false + } return a, true } diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go index 8355360..b07b83e 100644 --- a/internal/web/handlers/handlers_backup.go +++ b/internal/web/handlers/handlers_backup.go @@ -13,7 +13,6 @@ import ( "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/store" - "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/validate" ) @@ -28,6 +27,9 @@ const maxImportBytes = 1 << 20 // 1 MiB // and the domain import are separate actions with different risk, so each gets // its own card here rather than sharing a block on the domain list. func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } h.renderBackupPage(w, r, http.StatusOK, "") } @@ -42,14 +44,13 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat // password), importErr to the import card, so neither message appears under the // wrong form. func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { - h.view.Render(w, status, "backup", map[string]any{ - "Title": "SelfPost — backup", - "User": auth.CurrentUser(r), - "Active": "backup", - "ImportErr": importErr, - "BackupErr": backupErr, - "MinPwLen": validate.MinSecretFilePasswordLen, - }) + data := h.pageBase(r) + data["Title"] = "SelfPost — backup & migration" + data["Active"] = "backup" + data["ImportErr"] = importErr + data["BackupErr"] = backupErr + data["MinPwLen"] = validate.MinSecretFilePasswordLen + h.view.Render(w, status, "backup", data) } // HandleBackup streams a full-server backup as a download (architecture.md § @@ -61,6 +62,9 @@ func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, // way out, so the file that lands on their disk — wherever it is copied // afterwards — is useless without the password. func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } password, pwErr := secretFilePassword(r) if pwErr != "" { h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) @@ -184,6 +188,9 @@ func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) { // re-renders the backup page, where the import form lives, with a friendly // message. func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) if err := r.ParseMultipartForm(maxImportBytes); err != nil { h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).") diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index 4c29b89..d090be4 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -29,27 +29,31 @@ func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { - domains, err := h.domains.List() + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + domains, err := h.assignedDomains(p) if err != nil { logf("panel: dashboard: list domains: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.view.Render(w, status, "dashboard", map[string]any{ - "Title": "SelfPost", - "User": auth.CurrentUser(r), - "Active": "domains", - "Domains": h.domainRows(domains), - "Error": formErr, - "FormName": formName, - "Flash": dashboardFlash(r), - }) + data := h.pageBase(r) + data["Title"] = "SelfPost — domains" + data["Active"] = "domains" + data["Domains"] = h.domainRows(domains) + data["Error"] = formErr + data["FormName"] = formName + data["Flash"] = dashboardFlash(r) + h.view.Render(w, status, "dashboard", data) } func (h *Handlers) domainRows(domains []store.Domain) []domainRow { profileEmail := "" - if admin, err := h.store.GetAdmin(); err == nil { - profileEmail = admin.DMARCReportEmail + if email, err := h.store.GlobalDMARCReportEmail(); err == nil { + profileEmail = email } rows := make([]domainRow, len(domains)) var wg sync.WaitGroup @@ -82,6 +86,9 @@ func dashboardFlash(r *http.Request) string { // OpenDKIM reload), and redirects to the domain's page so the DNS record to // publish is shown (product.md). func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } if err := r.ParseForm(); err != nil { h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") return @@ -109,6 +116,9 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { // HandleDeleteConfirm shows the cascade warning before a domain is removed. func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } d, ok := h.lookupDomain(w, r) if !ok { return @@ -118,11 +128,15 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { "User": auth.CurrentUser(r), "Active": "domains", "Domain": d, + "IsGlobal": true, }) } // HandleDeleteDomain performs the deletion and returns to the domain list. func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } id, ok := parseDomainID(w, r) if !ok { return @@ -145,6 +159,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { // HandleReload re-applies both the OpenDKIM configuration and the Postfix // sender map on demand (architecture.md § Panel HTTP surface). func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } if err := h.domains.Resync(); err != nil { logf("panel: manual reload (opendkim): %v", err) http.Error(w, "reload failed", http.StatusInternalServerError) @@ -173,6 +190,11 @@ func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.D http.Error(w, "internal error", http.StatusInternalServerError) return store.Domain{}, false } + p, ok := h.principal(r) + if !ok || !p.CanAccessDomain(d.ID) { + http.NotFound(w, r) + return store.Domain{}, false + } return d, true } diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index fb4d936..57a0b4c 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -5,6 +5,7 @@ import ( "io/fs" "net/http" "net/url" + "sort" "strconv" "time" @@ -38,7 +39,9 @@ func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) { return } data["Title"] = "SelfPost — deliveries" - data["User"] = auth.CurrentUser(r) + for k, v := range h.pageBase(r) { + data[k] = v + } data["Active"] = "deliveries" h.view.Render(w, http.StatusOK, "deliveries", data) } @@ -85,12 +88,29 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !p.IsGlobal() { + allowed, err := h.assignedDomains(p) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !domainNameSet(allowed)[row.Domain] { + http.NotFound(w, r) + return + } + } row.Subject = mailhdr.DecodeSubject(row.Subject) logRows, logNote := h.deliveryLog(row) h.view.Render(w, http.StatusOK, "delivery", map[string]any{ "Title": "SelfPost — delivery", "User": auth.CurrentUser(r), "Active": "deliveries", + "IsGlobal": p.IsGlobal(), "Row": row, // The status in the panel's own badge vocabulary, so the headline reads // the same way as every other health signal in the panel. @@ -125,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 // 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. @@ -269,11 +300,31 @@ func deliveriesBackURL(r *http.Request) string { // string, queries the store, and assembles everything the template needs // (filter dropdown options plus the current selection, rows, and pagination). func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { + p, ok := h.principal(r) + if !ok { + return nil, errors.New("no principal") + } q := r.URL.Query() filter := store.SendLogFilter{ Domain: q.Get("domain"), AppLogin: q.Get("app"), } + + assigned, err := h.assignedDomains(p) + if err != nil { + return nil, err + } + 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) @@ -284,23 +335,35 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { if err != nil { return nil, err } - // Decode on the way out as well as on the way in: rows the journal-milter - // wrote before it decoded subjects itself still hold the raw header, and - // they are the ones an operator is most likely to be looking at. + 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)} } - domains, err := h.store.ListDomains() - if err != nil { - return nil, err + + domainNames := make([]string, 0, len(assigned)) + for _, d := range assigned { + domainNames = append(domainNames, d.Name) } - domainNames := make([]string, len(domains)) - for i, d := range domains { - domainNames[i] = d.Name + + loginSet := make(map[string]bool) + for _, d := range assigned { + apps, err := h.store.ListApplicationsByDomain(d.ID) + if err != nil { + return nil, err + } + for _, a := range apps { + loginSet[a.Login] = true + } } - logins, err := h.store.ListApplicationLogins() - if err != nil { - return nil, err + logins := make([]string, 0, len(loginSet)) + for login := range loginSet { + logins = append(logins, login) + } + sort.Strings(logins) + + if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] { + filter.AppLogin = "" } lastPage := 1 @@ -308,7 +371,7 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize) } return map[string]any{ - "Rows": rows, + "Rows": view, "FilterDomains": domainNames, "FilterApps": logins, "FilterDomain": filter.Domain, @@ -335,18 +398,25 @@ func parsePage(v string) int { // HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP // surface). func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } out, errText := readQueue() h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ - "Title": "SelfPost — mail queue", - "User": auth.CurrentUser(r), - "Active": "mail_queue", - "Output": out, - "Error": errText, + "Title": "SelfPost — mail queue", + "User": auth.CurrentUser(r), + "Active": "mail_queue", + "IsGlobal": true, + "Output": out, + "Error": errText, }) } // HandleMailQueueBody serves the HTMX polling fragment for the queue view. func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } out, errText := readQueue() h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{ "Output": out, @@ -369,18 +439,25 @@ func readQueue() (string, string) { // HandleSystemLog renders the System log page over mail.log (architecture.md § // Panel HTTP surface). func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } lines, errText := h.readLogTail() h.view.Render(w, http.StatusOK, "system_log", map[string]any{ - "Title": "SelfPost — system log", - "User": auth.CurrentUser(r), - "Active": "system_log", - "Lines": lines, - "Error": errText, + "Title": "SelfPost — system log", + "User": auth.CurrentUser(r), + "Active": "system_log", + "IsGlobal": true, + "Lines": lines, + "Error": errText, }) } // HandleSystemLogBody serves the HTMX polling fragment for the log-tail view. func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } lines, errText := h.readLogTail() h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{ "Lines": lines, diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index aaf49ab..2efeae5 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" ) // After log rotation renames mail.log away, Postfix takes about a second to @@ -252,6 +253,11 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, target, nil) + req = auth.RequestWithPrincipal(req, auth.Principal{ + ID: 1, + Username: "admin", + Role: auth.RoleGlobal, + }) if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" { req.SetPathValue("id", rest) } diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go index e7e9550..fb70123 100644 --- a/internal/web/handlers/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -19,24 +19,73 @@ type rateLimitInput struct { 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 { 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 + } + 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")) if err != nil { return rateLimitInput{}, err } 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 { 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) if err != nil || windowSeconds <= 0 { 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 { return } - in, err := parseRateLimitForm(r) + in, err := parseDomainRateLimitForm(r, h.l1Messages()) if err != nil { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, @@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) 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 { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, diff --git a/internal/web/handlers/handlers_ratelimit_test.go b/internal/web/handlers/handlers_ratelimit_test.go new file mode 100644 index 0000000..2cab88b --- /dev/null +++ b/internal/web/handlers/handlers_ratelimit_test.go @@ -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) + } +} diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index c731860..2518116 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -9,23 +9,29 @@ import ( ) func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } data := h.statusBody() - srv := h.dns.Server(h.cfg.Hostname, false) - data["Title"] = "SelfPost — status" data["User"] = auth.CurrentUser(r) data["Active"] = "status" + data["IsGlobal"] = true data["Flash"] = statusFlash(r) - data["Hostname"] = h.cfg.Hostname - data["PTR"] = srv.PTR h.view.Render(w, http.StatusOK, "status", data) } -func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) { +func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody()) } func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } h.dns.Server(h.cfg.Hostname, true) http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther) } @@ -58,6 +64,7 @@ func (h *Handlers) statusBody() map[string]any { } machine := h.machine.Sample() + srv := h.dns.Server(h.cfg.Hostname, false) overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status) return map[string]any{ @@ -71,6 +78,8 @@ func (h *Handlers) statusBody() map[string]any { "Cert": cert, "Sockets": sockets, "SocketStatus": socketStatus, + "Hostname": h.cfg.Hostname, + "PTR": srv.PTR, "OverallStatus": overall, "OverallHeading": overallHeading(overall), } diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go new file mode 100644 index 0000000..ce80617 --- /dev/null +++ b/internal/web/handlers/handlers_users.go @@ -0,0 +1,342 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/validate" + "golang.org/x/crypto/bcrypt" +) + +type userFormView struct { + FormErr string + FormUsername string + FormRole string + FormDomains map[int64]bool + FormPassword string +} + +// HandleUsers lists panel users (global only). +func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + rows, err := h.store.ListUserRows() + if err != nil { + logf("panel: list users: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + data := h.pageBase(r) + data["Title"] = "SelfPost — users" + data["Active"] = "users" + data["Users"] = rows + data["Flash"] = usersFlash(r) + h.view.Render(w, http.StatusOK, "users", data) +} + +func usersFlash(r *http.Request) string { + switch r.URL.Query().Get("done") { + case "created": + return "User created." + case "updated": + return "User updated." + case "deleted": + return "User deleted." + default: + return "" + } +} + +// HandleUserNew creates a panel user (global only). +func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + switch r.Method { + case http.MethodGet: + h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)}) + case http.MethodPost: + h.submitUserCreate(w, r) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// HandleUserEdit edits or deletes a panel user (global only). +func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + uid, ok := parseUserID(w, r) + if !ok { + return + } + u, err := h.store.GetUser(uid) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + http.NotFound(w, r) + return + } + logf("panel: get user %d: %v", uid, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + switch r.Method { + case http.MethodGet: + selected := make(map[int64]bool, len(u.DomainIDs)) + for _, id := range u.DomainIDs { + selected[id] = true + } + h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{ + FormUsername: u.Username, + FormRole: string(u.Role), + FormDomains: selected, + }) + case http.MethodPost: + if err := r.ParseForm(); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)}) + return + } + if r.PostFormValue("action") == "delete" { + h.submitUserDelete(w, r, u) + return + } + h.submitUserUpdate(w, r, u) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) { + domains, err := h.store.ListDomains() + if err != nil { + logf("panel: user form: list domains: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + data := h.pageBase(r) + if userID != 0 { + data["Title"] = "SelfPost — edit user" + } else { + data["Title"] = "SelfPost — create user" + } + data["Active"] = "users" + data["UserID"] = userID + data["Domains"] = domains + data["Error"] = view.FormErr + data["FormUsername"] = view.FormUsername + data["FormRole"] = view.FormRole + data["GlobalRole"] = store.RoleGlobal + data["FormDomains"] = view.FormDomains + data["FormPassword"] = view.FormPassword + data["IsEdit"] = userID != 0 + data["LastGlobalLocked"] = lastGlobalLocked(h, userID, view.FormRole) + h.view.Render(w, status, "user_form", data) +} + +func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool { + if userID == 0 || formRole != string(store.RoleGlobal) { + return false + } + n, err := h.store.CountGlobalUsers() + return err == nil && n <= 1 +} + +func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."}) + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + role := store.Role(r.PostFormValue("role")) + domainIDs := parseDomainIDs(r) + + if err := validate.Username(username); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) + return + } + if err := validate.AdminPassword(password); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) + return + } + if role != store.RoleGlobal && role != store.RoleDomainAdmin { + h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) + return + } + if role == store.RoleDomainAdmin && len(domainIDs) == 0 { + h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logf("panel: create user hash: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."}) + return + } + if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil { + if errors.Is(err, store.ErrUserExists) { + h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) + return + } + logf("panel: create user: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."}) + return + } + http.Redirect(w, r, "/users?done=created", http.StatusSeeOther) +} + +func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) { + if err := r.ParseForm(); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)}) + return + } + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + role := store.Role(r.PostFormValue("role")) + domainIDs := parseDomainIDs(r) + selected := domainIDSetFromForm(r) + + if username == "" { + username = u.Username + } + if err := validate.Username(username); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + if role != store.RoleGlobal && role != store.RoleDomainAdmin { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + if role == store.RoleDomainAdmin && len(domainIDs) == 0 { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + + if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { + n, err := h.store.CountGlobalUsers() + if err != nil || n <= 1 { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected}) + return + } + } + + if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { + n, err := h.store.CountGlobalUsers() + if err != nil || n <= 1 { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected}) + return + } + } + + hash := u.PasswordHash + if password != "" { + if err := validate.AdminPassword(password); err != nil { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logf("panel: update user hash: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + hash = string(newHash) + } + + if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil { + if errors.Is(err, store.ErrUserExists) { + h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + logf("panel: update user: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + + if role != u.Role { + if err := h.store.SetUserRole(u.ID, role); err != nil { + logf("panel: set user role: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + if role == store.RoleGlobal { + if err := h.store.ClearUserDomains(u.ID); err != nil { + logf("panel: clear user domains: %v", err) + } + } + } + + if role == store.RoleDomainAdmin { + if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil { + logf("panel: set user domains: %v", err) + h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected}) + return + } + } + + http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther) +} + +func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) { + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if u.ID == p.ID { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)}) + return + } + if err := h.store.DeleteUser(u.ID); err != nil { + if errors.Is(err, store.ErrLastGlobal) { + h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)}) + return + } + logf("panel: delete user %d: %v", u.ID, err) + h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)}) + return + } + http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther) +} + +func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return 0, false + } + return id, true +} + +func parseDomainIDs(r *http.Request) []int64 { + var ids []int64 + for _, v := range r.PostForm["domain_ids"] { + id, err := strconv.ParseInt(v, 10, 64) + if err == nil && id > 0 { + ids = append(ids, id) + } + } + return ids +} + +func domainIDSetFromForm(r *http.Request) map[int64]bool { + m := make(map[int64]bool) + for _, id := range parseDomainIDs(r) { + m[id] = true + } + return m +} diff --git a/internal/web/view/static/ibm-plex-mono-400.woff2 b/internal/web/view/static/ibm-plex-mono-400.woff2 new file mode 100644 index 0000000..a6c77d6 Binary files /dev/null and b/internal/web/view/static/ibm-plex-mono-400.woff2 differ diff --git a/internal/web/view/static/ibm-plex-mono-600.woff2 b/internal/web/view/static/ibm-plex-mono-600.woff2 new file mode 100644 index 0000000..59e4f52 Binary files /dev/null and b/internal/web/view/static/ibm-plex-mono-600.woff2 differ diff --git a/internal/web/view/static/ibm-plex-sans.woff2 b/internal/web/view/static/ibm-plex-sans.woff2 new file mode 100644 index 0000000..b757bc5 Binary files /dev/null and b/internal/web/view/static/ibm-plex-sans.woff2 differ diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index e2e155c..68f107e 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -2,7 +2,36 @@ layout so the panel's Content-Security-Policy can be a plain "default-src 'self'" with no inline-style exemption. Any rule added here must therefore stay here: an inline style="..." attribute in a - template is blocked by that policy and silently does nothing. */ + template is blocked by that policy and silently does nothing. + + The palette and the type below come from the mark's own approval sheet + (docs/assets/selfpost-proof.html) — brick, warm paper, IBM Plex. Before it + the panel was a default blue-on-cool-grey admin theme with the stamp pasted + onto it, and the two read as different pieces of work. */ + +/* The fonts ship with the panel rather than being asked for by name. Two + reasons, and the second is the one that matters. A named family resolves to + whatever the operator's machine has, so the mark — Plex converted to + outlines — would be the only Plex on the page. And ui-monospace is Consolas, + SF Mono or DejaVu Sans Mono depending on that machine, each with its own + advance width: the six-column send log and the mail.log tables would lay out + differently for every operator. Served from the panel's own origin, so the + policy above already covers them and needs no font-src of its own. + One variable file for the sans (100–700 in 46 KB, less than the four static + weights it replaces); mono has no variable release, so it is two files. + swap, so a cold load shows the system stack for a frame instead of nothing. */ +@font-face { + font-family: "IBM Plex Sans"; font-style: normal; font-weight: 100 700; + font-display: swap; src: url("/static/ibm-plex-sans.woff2") format("woff2"); +} +@font-face { + font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; + font-display: swap; src: url("/static/ibm-plex-mono-400.woff2") format("woff2"); +} +@font-face { + font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; + font-display: swap; src: url("/static/ibm-plex-mono-600.woff2") format("woff2"); +} /* Colour tokens. Light values live on :root; the dark media query below reassigns the same names rather than re-declaring every rule that uses @@ -10,39 +39,57 @@ property already carries the right value for the active scheme. */ :root { color-scheme: light dark; - --bg: #f6f7f9; --fg: #1b1f24; + --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; /* secondary text: .muted, th, timestamps, footers */ --card-bg: #fff; - --border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */ - --control-border: #cfd4da; /* input, select, textarea, action buttons */ + --border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */ + --control-border: #cbc8c1; /* input, select, textarea, action buttons */ --input-bg: #fff; - --code-bg: #f0f2f4; - --surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec; - --nav-active-bg: #e6ebf5; - --flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647; - --credential-bg: #fffbeb; --credential-border: #f5c518; - --danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2; - --st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6; - --st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89; - --st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca; - --st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9; + --code-bg: #efede9; + --surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8; + --nav-active-bg: #ede4de; + /* Brick, the mark's own colour, is the panel's accent — but as two values, + not one. A filled control carries --accent-fill with white on it; a link or + an active entry carries --accent-text, because the fill on a page + background is a colour to sit behind text, not to be text. In the light + scheme the two coincide. */ + --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; } @media (prefers-color-scheme: dark) { :root { - --bg: #14171a; --fg: #e6e8eb; - --card-bg: #1d2125; - --border: #2b3138; - --control-border: #2b3138; - --input-bg: #14171a; - --code-bg: #14171a; - --surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841; - --nav-active-bg: #22303f; - --flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b; - --credential-bg: #2a2408; --credential-border: #6b5a10; - --danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18; - --st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336; - --st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210; - --st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a; - --st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138; + --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; + /* Brick as drawn is about 2:1 against this background — a link nobody can + read. The fill lightens just enough to keep white on it above 4.5:1 + (6.9:1 as set), and the text value lightens further to clear the same bar + as text on the page. This is where the two values earn their keep. */ + --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; } } * { box-sizing: border-box; } @@ -54,7 +101,7 @@ read as the layout jumping. */ html { scrollbar-gutter: stable; } body { - font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + font: 400 15px/1.5 var(--font-sans); margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg); } /* The two columns — navigation on the left, page on the right — centred as a @@ -93,7 +140,11 @@ main.wide > * { max-width: none; } /* Longhands, not the margin shorthand: the shorthand would re-zero the auto side margins above and pin the heading to the column's edge, a measure's worth to the left of the card under it. */ -h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; } +/* Light rather than bold, which is the sheet's own setting for the name and + the one thing a variable font makes free. It is the page's title, not an + alarm: the size already says which line is the heading, and at 700 it shouted + over the card under it. */ +h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; } /* The full mark, on the two pages that have no navigation bar to carry the compact one. It takes the column's width so its edges line up with the card below it, capped at that column's own 24rem; height stays automatic because @@ -101,16 +152,50 @@ h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; } The subtitle stops resolving below 280px, which a 320px viewport still clears once the body's padding is taken off. */ .mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; } +/* The radius comes down from 10px across the panel: the mark is a stamp with a + square edge and a hairline, and a card rounded like a phone widget sat in + front of it as a different kind of object. 6px on the containers, 5px on the + controls inside them. */ .card { - background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 1.5rem; margin: 0 auto; } .card.narrow { max-width: 24rem; } +/* A narrow card as a direct child of main means the page is a short form: + every sibling in the column (heading, flash, footer) must share that 24rem + measure, or they centre on 48rem while the card centres on 24rem and the + left edges drift by half the difference (12rem / 192px at a 16px root — + measured). The column itself stays 64rem so the navigation does not move; + only the stack inside the column narrows. :has tracks the card rather than + a per-page class, so a new signed-in page that adds .card.narrow inherits + the alignment without a second stylesheet rule. + login/setup already shrink main to 24rem (below); this rule is then a no-op + relative to the parent. Do not replace that with :has alone: narrowing main + is what centres the signed-out block on the page when there is no nav. */ +main:has(> .card.narrow) > * { max-width: 24rem; } label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; } -input { - width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem; - border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit; +/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block + label and full-width input rules above: the box and its caption sit on one + line. Without this the caption drops under a stretched checkbox. */ +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; border-radius: 0; background: none; } +input { + 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; +} +/* Domain assignment on the user form: a bordered group under Role, not another + stacked field label. Legend replaces the usual label; the muted line is the + hint that used to sit where a field caption would. */ +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; } +fieldset > label.check:first-of-type { margin-top: 0.15rem; } /* One vocabulary for actions. Anything that performs an action looks like a button: a + {{if .Error}}

{{.Error}}

{{end}} - -

A DKIM key is generated for the domain; you then publish the - shown DNS record. Adding a domain does not create an application.

+{{end}}

Domains

@@ -31,7 +33,7 @@
{{.DNS}} {{.DKIMSelector}} {{.AppCount}} - Delete + {{if $.IsGlobal}}Delete{{else}}{{end}} {{end}} diff --git a/internal/web/view/templates/deliveries_rows.html b/internal/web/view/templates/deliveries_rows.html index ce9304f..b14610f 100644 --- a/internal/web/view/templates/deliveries_rows.html +++ b/internal/web/view/templates/deliveries_rows.html @@ -13,7 +13,7 @@ {{.From}} {{.To}} {{.Subject}} - {{.Status}} + {{.Status}} Details {{end}} diff --git a/internal/web/view/templates/delivery.html b/internal/web/view/templates/delivery.html index f03fccb..ee88623 100644 --- a/internal/web/view/templates/delivery.html +++ b/internal/web/view/templates/delivery.html @@ -18,7 +18,7 @@ {{.Row.Status}}

-← Back to deliveries +{{template "back_link" (back .BackURL "Back to deliveries")}} {{/* The two columns: what was recorded on the left, in what order it happened on the right. They are a pair — the facts are only worth reading against diff --git a/internal/web/view/templates/domain_delete.html b/internal/web/view/templates/domain_delete.html index fcb0978..e0701f4 100644 --- a/internal/web/view/templates/domain_delete.html +++ b/internal/web/view/templates/domain_delete.html @@ -1,7 +1,7 @@ {{define "content"}}

Delete {{.Domain.Name}}

-← Back to {{.Domain.Name}} +{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}

Confirm deletion

diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index f9ec373..bd072e6 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -1,7 +1,11 @@ +{{/* Wide so the .split pairs fill the column rather than the 48rem reading + measure (same pattern as Status). */}} +{{define "wide"}}wide{{end}} + {{define "content"}}

{{.Domain.Name}}

-← All domains +{{template "back_link" (back "/domains" "All domains")}} {{if .Flash}}
{{.Flash}}
{{end}} {{if .RateLimitErr}}
{{.RateLimitErr}}
{{end}} @@ -9,8 +13,8 @@ {{if .NewCred}}

New application password

-

This password is shown once only and is not - stored. Copy it now — if it is lost, regenerate a new one.

+

Shown once only and not stored. Copy it now + — if it is lost, regenerate a new one.

{{.NewCred.Login}} @@ -24,202 +28,279 @@
{{end}} -
-

DKIM DNS record

-

Publish this TXT record in the DNS for {{.Domain.Name}}. - It is not a secret and can be viewed at any time.

- - -
- {{.Record.Name}} - -
- - - TXT - - -
- {{.Record.Value}} - -
- -

Mail is signed with selector {{.Domain.DKIMSelector}}.

-
- -
-

SPF and DMARC records

-

These two are not generated the way the DKIM record above is — - they are policy, and the domain may already publish an SPF record for other - senders. SelfPost is a send-only relay — most operators have no inbox on the - sending domain, so the suggested DMARC record omits rua= unless a - report address is configured below or in Settings. - Publish both as TXT records.

- - -
- {{.Domain.Name}} - -
- - -
- {{.SPFExample}} - -
- -

A domain may have only one SPF record. If it already has one, - do not add a second — add this server's mechanism to the existing record - instead, before its all term.

- -
- - - - - - - -
- - -
- {{.DMARCName}} - -
- - -
- {{.DMARCExample}} - -
- - {{if .SameDomainRUA}} -

The report address is on this sending domain. SelfPost does - not receive inbound mail — use a mailbox elsewhere or wait for in-panel report - reception in a future release.

- {{end}} - - {{if .NeedsReportAuth}} - -
- {{.ReportAuthName}} - -
- - -
- {{.ReportAuthValue}} - -
- {{end}} - -

p=none changes nothing about delivery. Omit - rua= on a send-only domain, or point it at a mailbox that - receives inbound mail. Tighten to p=quarantine and then - p=reject once aggregate reports look clean.

-
- +{{/* Two rows of two checks (.check-cols). */}}

DNS status {{.DNS.Overall}}

-

What DNS publishes for {{.Domain.Name}} right - now, checked against the key this server signs with. Results are cached for a - few minutes — after publishing a record, use Re-check.

+

Cached a few minutes — use Re-check after + publishing.

- -

{{.DNS.DKIM.Detail}}

- {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} -{{end}}{{end}} +
+
+ +
+
+ + {{.Record.Name}} +
+
+ + TXT +
+
+ {{if .DNS.DKIM.Records}} + + {{range .DNS.DKIM.Records}}{{.}} +{{end}} + {{end}} + {{if ne .DNS.DKIM.Status "ok"}} +

{{.DNS.DKIM.Detail}}

+ {{end}} +
- -

{{.DNS.SPF.Detail}}

- {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} -{{end}}{{end}} -

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 authorises - the server through an include is reported as “cannot tell”, not as a failure.

+
+ +
+
+ + {{.Domain.Name}} +
+
+ + TXT +
+
+ {{if .DNS.SPF.Records}} + + {{range .DNS.SPF.Records}}{{.}} +{{end}} + {{end}} + {{if ne .DNS.SPF.Status "ok"}} +

{{.DNS.SPF.Detail}}

+ {{end}} +

Shallow check: literal address only, no include: / + redirect=.

+
- -

{{.DNS.DMARC.Detail}}

- {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} -{{end}}{{end}} +
+ +
+
+ + {{.DMARCName}} +
+
+ + TXT +
+
+ {{if .DNS.DMARC.Records}} + + {{range .DNS.DMARC.Records}}{{.}} +{{end}} + {{end}} + {{if ne .DNS.DMARC.Status "ok"}} +

{{.DNS.DMARC.Detail}}

+ {{else}} +

{{.DNS.DMARC.Detail}}

+ {{end}} +
- {{if .DNS.DMARCReportAuth.Status}} - -

{{.DNS.DMARCReportAuth.Detail}}

- {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} -{{end}}{{end}} - {{end}} +
+ {{if .DNS.DMARCReportAuth.Status}} + +
+
+ + {{.ReportAuthName}} +
+
+ + TXT +
+
+ {{if .DNS.DMARCReportAuth.Records}} + + {{range .DNS.DMARCReportAuth.Records}}{{.}} +{{end}} + {{end}} +

{{.DNS.DMARCReportAuth.Detail}}

+ {{else}} + +

Not required (no external rua=).

+ {{end}} +
+
-
-

Sending server settings

-

Point the mail client or script at these settings and - authenticate with an application login and password from the - Applications section below. They are the same for every - domain on this server.

+
+
+

DKIM and SPF records

- -
- {{.Hostname}} - -
- - - 465 — SSL/TLS (implicit){{if .SubmissionEnabled}} -587 — STARTTLS (submission){{end}} - -

Authentication is required on every port. The username is the - application's login (see the table below) and the password is the one shown - once when that application was created or its password regenerated — if it was - lost, generate a new one.

-
- -{{/* Create form above the list, the same order the domains page uses for - "Add a sending domain" above "Domains". */}} -
-

Add an application

-
- - - - - - -
- - +

DKIM

+
+
+ +
+ {{.Record.Name}} + +
+
+
+ + TXT +
- {{if .Error}}

{{.Error}}

{{end}} - - -

A strong password is generated and shown once. The login must - be unique across all domains and may contain letters, digits, '.', '-' and '_'.

+ +
+ {{.Record.Value}} + +
+ +

Not a secret. Signed with selector + {{.Domain.DKIMSelector}}.

+ +

SPF

+
+
+ +
+ {{.Domain.Name}} + +
+
+
+ + TXT +
+
+ + +
+ {{.SPFExample}} + +
+ +

Merge into an existing SPF if the domain already has one — + do not publish a second record.

+
+ +
+

DMARC record

+ +
+
+ +
+ {{.DMARCName}} + +
+
+
+ + TXT +
+
+ + +
+ {{.DMARCExample}} + +
+ + {{if .SameDomainRUA}} +

The report address is on this sending domain. SelfPost does + not receive inbound mail — use a mailbox elsewhere or wait for in-panel report + reception in a future release.

+ {{end}} + + {{if .NeedsReportAuth}} +

Report authorization

+
+
+ +
+ {{.ReportAuthName}} + +
+
+
+ + TXT +
+
+ + +
+ {{.ReportAuthValue}} + +
+ {{end}} + +

p=none does not affect delivery. Tighten to + p=quarantine then p=reject once reports look clean. + Report address is set under Domain settings.

+
+
+ +
+
+

Connection settings

+

Same for every domain. Authenticate with an application + login from below.

+ + +
+ {{.Hostname}} + +
+ + + 465 — SSL/TLS (implicit){{if .SubmissionEnabled}} +587 — STARTTLS (submission){{end}} + +

Auth required on every port. The password is shown once at + create or regenerate.

+
+ + {{/* Create form beside connection settings, mirroring "Add a sending domain" + above the domains list. */}} +
+

Add an application

+
+ + + + + + +
+ + +
+ + {{if .Error}}

{{.Error}}

{{end}} + +
+

Password shown once. Login unique across domains; letters, + digits, '.', '-' and '_'.

+

Applications

-

Each application is a SASL login/password an app or script - uses to send mail as this domain. A login may send from any address of the - domain (wildcard) or only from a fixed list of addresses.

+

SASL logins for this domain — wildcard (*@domain) or a fixed + address list.

{{if .Apps}}
    @@ -230,17 +311,12 @@ {{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}} {{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}

    - +
    - - - - + +
    @@ -249,44 +325,60 @@ data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
    -
    -
    - - -
    - - +
    + +
    +
    +
    +

    Optional trusted-IP override + {{if .HasLimit}}active{{else}}inactive{{end}}

    +

    One per line or comma-separated.

    +
    + +

    These IPs get a higher ceiling than the domain + (≤ level 1) and skip the domain check; everyone else uses + the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}} + if set, otherwise level 1.

    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + + {{if .HasLimit}} +
    + + +
    + {{end}} +
    - - -
    -
    -
    - - - - - - -
    - -
    - - {{if .HasLimit}} -
    - - -
    - {{end}}
@@ -298,84 +390,87 @@ {{end}}
-
-

Sending rate limit (domain)

-

Optional level-2 limit (spec 7.4): cap how many messages this - domain may send from its expected client IP(s) within a time window, summed - across all its applications. It counts messages — one message to many - recipients counts once. Leave the IP list empty to disable it and rely only on - the global level-1 limit. Applications that send from changing IPs should be - left unbound here.

+
+

Domain settings

-

Status: - {{if .DomainHasRL}}active{{else}}inactive (level-1 only){{end}}.

+
+
+

DMARC reports

+

Default comes from Settings; + override per domain here.

+
+
+ + - - - +
+ + +
+
+
+
+ +
+
- - - - - - - - - {{if .DomainHasRL}} -
- - -
- {{end}} +
+

Level-2 rate limit + {{if .DomainHasRL}}active{{else}}inactive{{end}}

+

Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — Settings.

+
+
+
+
+ + +
+
+ + +
+
+

Applies to every client IP on this domain. Leave the + message limit empty to use level 1 only.

+
+
+
+ + {{if .DomainHasRL}} +
+ + +
+ {{end}} +
+
+
-
-

Export domain

-

Download this domain to move it to another SelfPost instance: - its DKIM key, selector and every application with its working password. On - import the DNS record stays the same, so no DNS change is needed.

-

The export file is a secret — it contains the - private DKIM key and application passwords. Transfer it securely and delete it - after the import, or encrypt it below and move a .spde file - (SelfPost domain export) instead; the import form asks for the password.

- {{if .ExportErr}}

{{.ExportErr}}

{{end}} -
- {{template "encryptfields" .}} - -
-
+
+
+

Export domain

+

Secret file — transfer securely, or encrypt + below as .spde.

+ {{if .ExportErr}}

{{.ExportErr}}

{{end}} +
+ {{template "encryptfields" .}} + +
+
-
-

Danger zone

-

Deleting this domain also deletes its DKIM key and every - application bound to it.

- Delete domain -
-{{end}} - -{{/* The domain page's section index, shown in the navigation column (see the - "sections" block in layout.html). This is the panel's longest page — the - DNS records to publish, the checks on them, the applications and two rate - limits — and setting a domain up means going back and forth between them. - The freshly generated password is only listed while it is on the page: it - is the one card that is not always there, and the one nothing should scroll - away from silently. */}} -{{define "sections"}} -
-

On this page

- {{if .NewCred}}New application password{{end}} - DKIM DNS record - SPF and DMARC records - DNS status - Sending server settings - Add an application - Applications - Sending rate limit - Export domain - Danger zone +
+

Danger zone

+

Deletes the DKIM key and every application on this domain.

+ Delete domain +
{{end}} diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index d1d868e..9e8593b 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -61,22 +61,26 @@ {{define "nav"}} {{end}} -{{/* The index of the current page's own sections, shown in the navigation - column under the page entries. Most pages are one or two cards and need no - index, so the block is empty here and only the pages that are genuinely - long — the domain page, the status page — redefine it with their own list - (see .sections in panel.css for what it looks like, and panel.js for the - marking of the section in view). Each page's template file is parsed after - this one, so its definition replaces this empty one; a page that defines - nothing keeps it and renders no index. */}} -{{define "sections"}}{{end}} - {{/* Whether the page takes the column whole. Empty here, so a page is held to - the reading measure unless it says otherwise; the three pages that are - tables of data or raw log lines redefine this as the word "wide", which - lands in
's class list (see main.wide in panel.css). Same mechanism - as "sections" above: each page's template file is parsed after this one, - so its definition replaces the empty one. */}} + the reading measure unless it says otherwise; the pages that are tables of + data, raw log lines or side-by-side cards redefine this as the word "wide", + which lands in
's class list (see main.wide in panel.css). Each + page's template file is parsed after this one, so its definition replaces + the empty one. */}} {{define "wide"}}{{end}} +{{/* back_link — up-navigation on drill-down pages. Invoke with the "back" + function, e.g. {{template "back_link" (back "/users" "Back to users")}}. + Place it directly under the page

and above the cards (see .back in + panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}} +{{define "back_link"}} +← {{.Label}} +{{end}} + {{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they inherit the link's colour through currentColor, cost no extra request, and need no exemption from the panel's "default-src 'self'" policy. Each is @@ -117,6 +119,15 @@ two drawers with handles is the picture of an archive. */}} {{define "icon-backup"}}{{end}} {{define "icon-account"}}{{end}} +{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks + from behind; front is a full silhouette matching icon-account. Heads share + one baseline; shoulder arcs share one floor — otherwise the pair looks + lopsided at 16 px. */}} +{{define "icon-users"}}{{end}} +{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone + read as brightness, while the toothed rim is the picture of configuration. + viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}} +{{define "icon-settings"}}{{end}} {{/* Sign out is the one icon here that marks an action rather than a page: an open door with the arrow leading out of it. */}} {{define "icon-sign-out"}}{{end}} diff --git a/internal/web/view/templates/account.html b/internal/web/view/templates/settings.html similarity index 50% rename from internal/web/view/templates/account.html rename to internal/web/view/templates/settings.html index e515568..27630ec 100644 --- a/internal/web/view/templates/account.html +++ b/internal/web/view/templates/settings.html @@ -1,14 +1,21 @@ +{{/* Wide enough for credentials and DMARC settings side by side (see .split + in panel.css, as on a delivery's page). */}} +{{define "wide"}}wide{{end}} + {{define "content"}}

Settings

{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Error}}

{{.Error}}

{{end}} + +{{if .ShowDMARC}} +
+
+
+

Panel credentials

+

These are the credentials for this control panel only. + Applications keep their own logins and passwords, which are not affected.

-
-

Panel credentials

-

These are the credentials for this control panel only. - Applications keep their own logins and passwords, which are not affected.

- {{if .Error}}

{{.Error}}

{{end}} - @@ -22,8 +29,10 @@ +
-

DMARC aggregate reports

+
+

DMARC aggregate reports

Optional default rua= address for every sending domain (can be overridden per domain). Use a mailbox on a domain that receives inbound mail. SelfPost is send-only today; a future release will @@ -58,11 +67,61 @@ {{end}}{{end}} {{end}} {{end}} +

+
+ + +

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.

+ +{{else}} +
+

Panel credentials

+

These are the credentials for this control panel only. + Applications keep their own logins and passwords, which are not affected.

+
+ + + + + + + + + + +

Leave both new-password fields empty to change the username - or DMARC address only. Changing the password signs out every other session; + only. Changing the password signs out every other session; this one stays signed in.

{{end}} + +
+

Sending rate limits

+

Configured in .env / Compose; restart the + container to change level 1. Domain and application ceilings are set on + each domain's page.

+ + +

{{.L1Messages}} messages / {{.L1Window}} seconds

+

RATE_LIMIT_MESSAGES_PER_IP / + RATE_LIMIT_WINDOW_SECONDS. Hard ceiling for every connecting IP; + the panel cannot raise a domain or application limit above this.

+ + +

Optional ceiling for all senders on a domain. When + unset, only level 1 applies. Must be ≤ level 1.

+ + +

Optional override: list client IPs and 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).

+
+{{end}} diff --git a/internal/web/view/templates/status.html b/internal/web/view/templates/status.html index e0f020d..109b14f 100644 --- a/internal/web/view/templates/status.html +++ b/internal/web/view/templates/status.html @@ -1,34 +1,14 @@ +{{/* Wide so Machine|Processes and the other .split pairs fill the column + rather than the 48rem reading measure. */}} +{{define "wide"}}wide{{end}} + {{define "content"}} -

Server status

+

Status

{{if .Flash}}
{{.Flash}}
{{end}} {{template "status_body" .}} -
-

Hostname and reverse DNS {{.PTR.Status}}

-

Receiving servers check that the name this server announces - resolves to its address and that the address resolves back to the same - name (forward-confirmed reverse DNS). A missing or mismatched reverse record is - the most common reason self-hosted mail is rejected or scored as spam. The - reverse record is set at the hosting provider, not in the domain's DNS zone.

- - - {{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}} - - {{if .PTR.Records}} - - {{range .PTR.Records}}{{.}} -{{end}} - {{end}} - -

{{.PTR.Detail}}

- -
- -
-
-

Configuration

Regenerates the OpenDKIM and Postfix configuration from the @@ -41,22 +21,3 @@

{{end}} - -{{/* The status page's section index, shown in the navigation column (see the - "sections" block in layout.html). The first six cards are the ones the - polling fragment replaces on a timer; their ids are part of - status_body.html and do not change with the reading, so the links here hold - across a refresh. */}} -{{define "sections"}} - -{{end}} diff --git a/internal/web/view/templates/status_body.html b/internal/web/view/templates/status_body.html index 22f551b..2d901e0 100644 --- a/internal/web/view/templates/status_body.html +++ b/internal/web/view/templates/status_body.html @@ -5,108 +5,125 @@

{{.OverallHeading}}

-
-

Processes {{.ProcessStatus}}

- {{if .ProcessError}} -

Could not ask supervisord for the process list.

- {{else}} - - - - {{range .Processes}} - - - - - - {{end}} - -
ProgramStateDetail
{{.Name}}{{.State}}{{.Detail}}
- {{end}} +
+
+

Machine {{.Machine.Status}}

+ + + + + + + + + + + + + + + + + + + +
ResourceUsageDetail
CPU + {{if .Machine.CPU.Measured}} + {{.Machine.CPU.BusyText}} + {{.Machine.CPU.BusyText}} + {{else}}—{{end}} + {{.Machine.CPU.Detail}}
Memory + {{if .Machine.Memory.Measured}} + {{.Machine.Memory.PctText}} + {{.Machine.Memory.PctText}} + {{else}}—{{end}} + {{.Machine.Memory.Detail}}
Network + {{if .Machine.Network.Measured}} + ↓ {{.Machine.Network.InRateText}}
↑ {{.Machine.Network.OutRateText}} + {{else}}—{{end}} +
+ {{range .Machine.Network.Interfaces}} +
{{.Name}}: {{.InText}} in, {{.OutText}} out
+ {{end}} + {{if .Machine.Network.Detail}}
{{.Machine.Network.Detail}}
{{end}} +
+
+ +
+

Processes {{.ProcessStatus}}

+ {{if .ProcessError}} +

Could not ask supervisord for the process list.

+ {{else}} + + + + {{range .Processes}} + + + + + + {{end}} + +
ProgramStateDetail
{{.Name}}{{.State}}{{.Detail}}
+ {{end}} +
-
-

Machine {{.Machine.Status}}

-

Processor, memory and network of the machine this container - runs on, read from the kernel's counters. CPU and throughput are rates, so - they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}} - since the previous refresh rather than this instant.

- - - - - - - - - - - - - - - - - - - -
ResourceUsageDetail
CPU - {{if .Machine.CPU.Measured}} - {{.Machine.CPU.BusyText}} - {{.Machine.CPU.BusyText}} - {{else}}—{{end}} - {{.Machine.CPU.Detail}}
Memory - {{if .Machine.Memory.Measured}} - {{.Machine.Memory.PctText}} - {{.Machine.Memory.PctText}} - {{else}}—{{end}} - {{.Machine.Memory.Detail}}
Network - {{if .Machine.Network.Measured}} - ↓ {{.Machine.Network.InRateText}}
↑ {{.Machine.Network.OutRateText}} - {{else}}—{{end}} -
- {{range .Machine.Network.Interfaces}} -
{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}
- {{end}} - {{if .Machine.Network.Detail}}
{{.Machine.Network.Detail}}
{{end}} -
+
+
+

Mail queue {{.QueueStatus}}

+ {{if .QueueError}} +

{{.QueueError}}

+ {{else}} +

{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}

+ View queue + {{end}} +
+ +
+

TLS certificate {{.Cert.Status}}

+ {{if not .Cert.NotAfter.IsZero}} + + {{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}} + {{end}} +

{{.Cert.Detail}}

+
-
-

Mail queue {{.QueueStatus}}

- {{if .QueueError}} -

{{.QueueError}}

- {{else}} -

{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}

- Full queue - {{end}} -
+
+
+

Milter sockets {{.SocketStatus}}

+ + + + {{range .Sockets}} + + + + + + {{end}} + +
MilterStateDetail
{{.Name}}{{.Status}}{{.Detail}}
+
-
-

TLS certificate {{.Cert.Status}}

-

The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}. - It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.

- {{if not .Cert.NotAfter.IsZero}} - - {{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}} - {{end}} -

{{.Cert.Detail}}

-
+
+

Hostname and reverse DNS {{.PTR.Status}}

-
-

Milter sockets {{.SocketStatus}}

- - - - {{range .Sockets}} - - - - - - {{end}} - -
MilterSocketState
{{.Name}}{{.Path}}{{.Status}} {{.Detail}}
+ + {{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}} + + {{if .PTR.Records}} + + {{range .PTR.Records}}{{.}} +{{end}} + {{end}} + +
+ +
+
{{end}} diff --git a/internal/web/view/templates/user_form.html b/internal/web/view/templates/user_form.html new file mode 100644 index 0000000..2491d5d --- /dev/null +++ b/internal/web/view/templates/user_form.html @@ -0,0 +1,42 @@ +{{define "content"}} +

{{if .IsEdit}}Edit user{{else}}Create user{{end}}

+ +{{template "back_link" (back "/users" "Back to users")}} + +
+ {{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + + {{if .LastGlobalLocked}}{{end}} + + {{if .LastGlobalLocked}}

The only global administrator cannot be demoted.

{{end}} + +
+ Assigned domains +

Required for domain administrators.

+ {{range .Domains}} + + {{end}} +
+ + + {{if .IsEdit}} + + {{if .LastGlobalLocked}}

The only global administrator cannot be deleted.

{{end}} + {{end}} +
+
+{{end}} diff --git a/internal/web/view/templates/users.html b/internal/web/view/templates/users.html new file mode 100644 index 0000000..5e932b5 --- /dev/null +++ b/internal/web/view/templates/users.html @@ -0,0 +1,28 @@ +{{define "content"}} +

Users

+ +{{if .Flash}}
{{.Flash}}
{{end}} + +
+

Create user

+ {{if .Users}} + + + + + + {{range .Users}} + + + + + + + {{end}} + +
UsernameRoleDomains
{{.User.Username}}{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}Edit
+ {{else}} +

No panel users besides the global administrator yet.

+ {{end}} +
+{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 767a6dc..781c509 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -29,79 +29,6 @@ func TestEveryPageResolvesNav(t *testing.T) { } } -// The section index each long page shows in the navigation column works by -// overriding an empty "sections" block defined in the layout, which only holds -// as long as the layout is parsed before the page's own files (see pageFiles). -// Reverse that order and every index would silently disappear — the empty -// definition would win and no page would fail to render — so the two ends are -// asserted here: the long pages produce a list, and a page that defines nothing -// produces nothing at all. -func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { - engine, err := New("test") - if err != nil { - t.Fatalf("New: %v", err) - } - // Anchors the index links to, taken from the page's own cards. - wantAnchors := map[string]string{ - "status": `href="#certificate"`, - "domain_detail": `href="#danger"`, - } - for name, page := range engine.Pages() { - var buf bytes.Buffer - // The domain page's index hides the freshly generated credential entry - // unless one is on the page, so the data map carries the key it reads. - if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil { - t.Fatalf("execute sections for %q: %v", name, err) - } - out := buf.String() - anchor, wanted := wantAnchors[name] - switch { - case wanted && !strings.Contains(out, anchor): - t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out) - case !wanted && strings.TrimSpace(out) != "": - t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out) - } - } -} - -// A section link that points at no card is a link that does nothing, and -// nothing about rendering the page says so. Every anchor the index offers must -// name an element the same page defines an id for. -func TestSectionLinksPointAtCardsThatExist(t *testing.T) { - engine, err := New("test") - if err != nil { - t.Fatalf("New: %v", err) - } - // The pages that carry an index; both are checked with a credential shown, - // which is the domain page's one conditional entry. - for _, name := range []string{"status", "domain_detail"} { - var index bytes.Buffer - if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { - t.Fatalf("execute sections for %q: %v", name, err) - } - // The cards are spread over the page's template files, so the ids are - // collected from the files rather than from a rendered page — rendering - // one would need the whole of a handler's data map. - ids := map[string]bool{} - for _, file := range pageFiles[name] { - body, err := fs.ReadFile(assetsFS, file) - if err != nil { - t.Fatalf("read %s: %v", file, err) - } - // Cards only: a form field's id is not somewhere a section link may - // land, so matching those too would weaken the check. - for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) { - ids[m[1]] = true - } - } - for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) { - if !ids[m[1]] { - t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1]) - } - } - } -} - // The version comes from render(), not from each handler's data map, so the // footer is only correct as long as every page composes with the layout and // render keeps supplying the key. Both are asserted here rather than trusted. @@ -207,8 +134,9 @@ func TestNavMarksActivePage(t *testing.T) { } var buf bytes.Buffer err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{ - "User": "admin", - "Active": "mail_queue", + "User": "admin", + "Active": "mail_queue", + "IsGlobal": true, }) if err != nil { t.Fatalf("execute nav: %v", err) @@ -234,8 +162,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { } var buf bytes.Buffer if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{ - "User": "admin", - "Active": "status", + "User": "admin", + "Active": "status", + "IsGlobal": true, }); err != nil { t.Fatalf("execute nav: %v", err) } @@ -261,7 +190,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true} + wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true} for name, page := range engine.Pages() { var buf bytes.Buffer if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { @@ -277,6 +206,101 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { } } +// The domain page pairs cards the same way Status does: three .split rows +// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications +// and Domain settings are full-width; DNS status and Domain settings (and the +// application Edit panel) use .check-cols. Losing a row silently stacks again. +func TestDomainDetailPageHasPairedCards(t *testing.T) { + body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html") + if err != nil { + t.Fatalf("read domain_detail: %v", err) + } + src := string(body) + if got := strings.Count(src, `class="split"`); got != 3 { + t.Errorf("domain detail has %d .split rows, want 3", got) + } + if !strings.Contains(src, `class="check-cols"`) { + t.Error("domain detail is missing the check-cols grid") + } + if !strings.Contains(src, `class="panel-toggle t-edit"`) { + t.Error("application Edit should be a single panel-toggle") + } + if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) || + strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) { + t.Error("application Edit mode and Rate limit should be one Edit button") + } + for _, id := range []string{ + `id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`, + `id="connection"`, `id="add-application"`, `id="applications"`, + `id="domain-settings"`, `id="export"`, `id="danger"`, + } { + if !strings.Contains(src, id) { + t.Errorf("domain detail is missing %s", id) + } + } + if strings.Contains(src, `id="rate-limit"`) { + t.Error("domain rate limit should live inside domain-settings, not its own card") + } + if strings.Contains(src, `id="d_ips"`) { + t.Error("domain rate limit must not ask for client IPs") + } + if !strings.Contains(src, "{{.L1Messages}}") && !strings.Contains(src, "{{$.L1Messages}}") { + t.Error("domain rate limit should show the L1 message count") + } + if !strings.Contains(src, "Level 1 backstop") { + t.Error("domain rate limit should show a Level 1 backstop line") + } + if !strings.Contains(src, "Trusted client IPs") { + t.Error("application override should ask for trusted client IPs") + } + if strings.Contains(src, `id="spf-dmarc"`) { + t.Error("SPF should sit with DKIM, not with DMARC") + } +} + +func TestSettingsPageDocumentsRateLimits(t *testing.T) { + body, err := fs.ReadFile(assetsFS, "templates/settings.html") + if err != nil { + t.Fatalf("read settings: %v", err) + } + src := string(body) + if !strings.Contains(src, `id="rate-limits"`) { + t.Error("settings should include a sending rate limits card") + } + for _, want := range []string{ + "RATE_LIMIT_MESSAGES_PER_IP", + "Level 2 — domain", + "trusted IPs", + "{{.L1Messages}} messages / {{.L1Window}} seconds", + } { + if !strings.Contains(src, want) { + t.Errorf("settings rate limits card missing %q", want) + } + } +} + +func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { + drillDown := map[string]bool{ + "user_form.html": true, + "domain_detail.html": true, + "domain_delete.html": true, + "delivery.html": true, + } + forEachTemplate(t, func(name, body string) { + if !drillDown[name] { + return + } + if !strings.Contains(body, `template "back_link"`) { + t.Errorf("%s is a drill-down page but does not use the shared back_link template", name) + } + backIdx := strings.Index(body, `template "back_link"`) + cardIdx := strings.Index(body, `class="card`) + if cardIdx >= 0 && backIdx > cardIdx { + t.Errorf("%s places the back link after the first card", name) + } + }) +} + // Since the panel root redirects to the status page, a link left pointing at // "/" silently lands on the wrong screen instead of failing — so no template may // contain one. @@ -351,17 +375,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) { "opendkim", "FATAL", "Mail queue is empty", "mail.example.com", "203.0.113.10 → no PTR record", `action="/reload"`, `hx-get="/status/fragment"`, `class="st st-error"`, + // Three .split rows inside the polled fragment: machine|processes, + // queue|certificate, and sockets|hostname. Ids stay on the cards. + `id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, + `action="/status/recheck"`, // The machine card: the bars carry their reading in an attribute // (the CSP rules out sizing them with a style), and the figures are // printed beside them for anything that does not render a meter. ` markup. + "back": func(href, label string) map[string]string { + return map[string]string{"Href": href, "Label": label} + }, + } +} + // Page returns a parsed page template by logical name. It is exported for // template guard tests that assert structural properties across all pages. func (e *Engine) Page(name string) *template.Template { diff --git a/internal/web/web.go b/internal/web/web.go index 2341775..466150c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -70,6 +70,11 @@ type Config struct { // checks must not go through the system resolver — see dnscheck's // externalResolver — so this is how a closed network points them at its own. DNSResolvers []string + // RateLimitMessagesPerIP and RateLimitWindowSeconds are the level-1 + // Postfix anvil backstop (env RATE_LIMIT_*), mirrored into the panel for + // display and to cap domain/app level-2 ceilings (guide § Rate limiting). + RateLimitMessagesPerIP int + RateLimitWindowSeconds int } // Server is the panel HTTP application. @@ -96,15 +101,17 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, }, v, setupTokenPath) h := handlers.New(st, domains, apps, handlers.Config{ - Hostname: cfg.Hostname, - SubmissionEnabled: cfg.SubmissionEnabled, - MailLogPath: cfg.MailLogPath, - DataDir: cfg.DataDir, - DBPath: cfg.DBPath, - Version: cfg.Version, - TLSCertFile: cfg.TLSCertFile, - OpenDKIMSocket: cfg.OpenDKIMSocket, - JournalSocket: cfg.JournalSocket, + Hostname: cfg.Hostname, + SubmissionEnabled: cfg.SubmissionEnabled, + MailLogPath: cfg.MailLogPath, + DataDir: cfg.DataDir, + DBPath: cfg.DBPath, + Version: cfg.Version, + TLSCertFile: cfg.TLSCertFile, + OpenDKIMSocket: cfg.OpenDKIMSocket, + JournalSocket: cfg.JournalSocket, + RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, + RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) return &Server{cfg: cfg, auth: a, handlers: h}, nil } @@ -129,7 +136,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/logout", s.auth.HandleLogout) authed := http.NewServeMux() - authed.HandleFunc("GET /{$}", redirectToStatus) + authed.HandleFunc("GET /{$}", redirectHome) authed.HandleFunc("GET /status", h.HandleStatus) authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment) authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck) @@ -151,7 +158,14 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /reload", h.HandleReload) - authed.HandleFunc("/account", h.HandleAccount) + authed.HandleFunc("/settings", h.HandleAccount) + authed.HandleFunc("/account", redirectSettings) + + authed.HandleFunc("GET /users", h.HandleUsers) + authed.HandleFunc("GET /users/new", h.HandleUserNew) + authed.HandleFunc("POST /users/new", h.HandleUserNew) + authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit) + authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit) authed.HandleFunc("GET /backup", h.HandleBackupPage) authed.HandleFunc("POST /backup", h.HandleBackup) @@ -168,7 +182,21 @@ func (s *Server) Handler() http.Handler { return s.secure(mux) } -func redirectToStatus(w http.ResponseWriter, r *http.Request) { +// redirectSettings sends legacy /account bookmarks to /settings (308 preserves POST). +func redirectSettings(w http.ResponseWriter, r *http.Request) { + target := "/settings" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusPermanentRedirect) +} + +func redirectHome(w http.ResponseWriter, r *http.Request) { + p, ok := auth.PrincipalFromRequest(r) + if ok && !p.IsGlobal() { + http.Redirect(w, r, "/domains", http.StatusSeeOther) + return + } http.Redirect(w, r, "/status", http.StatusSeeOther) } diff --git a/test/e2e/logrotate_check.go b/test/e2e/logrotate_check.go new file mode 100644 index 0000000..98fde8c --- /dev/null +++ b/test/e2e/logrotate_check.go @@ -0,0 +1,97 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// checkLogrotateConfigMode verifies the image pins /etc/logrotate.d/mail at 0644 +// so logrotate will not silently ignore it (docs/plans/logrotate-mode.md). +func checkLogrotateConfigMode(s *stack) error { + mode, err := s.execIn("selfpost", "stat", "-c", "%a", "/etc/logrotate.d/mail") + if err != nil { + return fmt.Errorf("stat logrotate config: %w", err) + } + mode = strings.TrimSpace(mode) + if mode != "644" { + return fmt.Errorf("/etc/logrotate.d/mail mode is %q, want 644", mode) + } + return nil +} + +// checkLogrotateRotation forces a rotation and checks the recreated mail.log is +// panel-readable (0640 postfix:selfpost per build/logrotate-mail.conf). +func checkLogrotateRotation(s *stack) error { + const logPath = "/data/log/mail.log" + marker := "e2e-logrotate-marker\n" + if _, err := s.execIn("selfpost", "sh", "-c", + fmt.Sprintf("printf %q >> %s", marker, logPath)); err != nil { + return fmt.Errorf("write mail.log: %w", err) + } + if _, err := s.execIn("selfpost", "logrotate", "-f", "/etc/logrotate.d/mail"); err != nil { + return fmt.Errorf("logrotate -f: %w", err) + } + rotated, err := s.execIn("selfpost", "sh", "-c", "test -f /data/log/mail.log.1 && echo yes") + if err != nil || strings.TrimSpace(rotated) != "yes" { + return fmt.Errorf("expected /data/log/mail.log.1 after forced rotation (out=%q err=%v)", rotated, err) + } + mode, err := s.execIn("selfpost", "stat", "-c", "%a", logPath) + if err != nil { + return fmt.Errorf("stat rotated mail.log: %w", err) + } + if strings.TrimSpace(mode) != "640" { + return fmt.Errorf("new %s mode is %q, want 640", logPath, strings.TrimSpace(mode)) + } + body, err := s.execIn("selfpost", "grep", "-F", strings.TrimSpace(marker), "/data/log/mail.log.1") + if err != nil || !strings.Contains(body, strings.TrimSpace(marker)) { + return fmt.Errorf("rotated file does not contain marker (out=%q err=%v)", body, err) + } + return nil +} + +// TestImageBuildPreservesLogrotateMode builds from a context where +// logrotate-mail.conf is group-writable and checks the image still ships 0644. +func TestImageBuildPreservesLogrotateMode(t *testing.T) { + conf := filepath.Join(h.repoRoot, "build", "logrotate-mail.conf") + info, err := os.Stat(conf) + if err != nil { + t.Fatalf("stat source config: %v", err) + } + origMode := info.Mode().Perm() + + if err := os.Chmod(conf, origMode|0o020); err != nil { + t.Fatalf("chmod g+w source config: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(conf, origMode) + }) + + tag := "selfpost:e2e-logrotate-mode" + build := exec.Command("docker", "build", + "-f", filepath.Join(h.repoRoot, "build", "Dockerfile"), + "-t", tag, + "--build-arg", "VERSION=e2e", + h.repoRoot, + ) + build.Env = os.Environ() + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("docker build with group-writable context config: %v\n%s", err, out) + } + t.Cleanup(func() { + _, _ = exec.Command("docker", "rmi", "-f", tag).CombinedOutput() + }) + + run := exec.Command("docker", "run", "--rm", tag, "stat", "-c", "%a", "/etc/logrotate.d/mail") + run.Env = os.Environ() + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("stat in image: %v\n%s", err, out) + } + if strings.TrimSpace(string(out)) != "644" { + t.Fatalf("image logrotate config mode is %q, want 644", strings.TrimSpace(string(out))) + } +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 4135598..8a0deb5 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -105,6 +105,12 @@ func TestE2E(t *testing.T) { if err := checkSupervisorProcesses(h); err != nil { t.Fatal(err) } + if err := checkLogrotateConfigMode(h); err != nil { + t.Fatal(err) + } + if err := checkLogrotateRotation(h); err != nil { + t.Fatal(err) + } if err := waitForPanelReady(); err != nil { t.Fatal(err) } diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index 2b16495..80773f2 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a return appLogin, password, nil } -// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting) -// on either a domain (/domains/{id}/ratelimit) or an application -// (/applications/{id}/ratelimit). +// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application +// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP +// override; domain ceilings are posted without IPs. func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { - resp, body, err := c.postForm(path, url.Values{ - "allowed_ips": {allowedIP}, + vals := url.Values{ "max_messages": {fmt.Sprintf("%d", maxMessages)}, "window_seconds": {fmt.Sprintf("%d", windowSeconds)}, - }) + } + if allowedIP != "" { + vals.Set("allowed_ips", allowedIP) + } + resp, body, err := c.postForm(path, vals) if err != nil { return err }