release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes
[1.2.0] 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 Fixed: - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the - panel: on signed-in pages with a narrow card (**Settings**, the user form) Changed: - panel: navigation session icons are distinct — **Settings** uses a gear, - panel: `/` redirects domain administrators to `/domains`; global users still - panel: **Sign out** in the navigation column uses the same type size and - panel: **visual style** brought in line with the SelfPost mark — brick accent - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown … (1 more in CHANGELOG) [1.2.1] 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 - panel: the **Users** navigation icon was two full silhouettes with staggered - panel: the **Settings** navigation icon was a sun-with-rays (circle plus - panel: the user create/edit form placed «Back to users» at the bottom of the Changed: - panel: the user edit form disables role change and delete for the only global - panel: the user create/edit form hides **Assigned domains** when the role is - panel: **Settings** shows panel credentials and DMARC aggregate reports side [1.2.2] 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: Fixed: - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid - panel: on the user create/edit form, **Assigned domains** checkboxes stacked [1.2.3] 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; - panel: Domains list — **Add domain** sits beside the domain field; the - panel: page URLs, browser titles, and headings are aligned — **Settings** is Fixed: - image: `mail.log` rotation no longer silently stops when the build context [1.2.4] 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 Fixed: - panel: on the domain DNS status grid, the Type (TXT) field height matches [1.2.5] 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 Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5]. Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
This commit is contained in:
@@ -7,5 +7,4 @@ docs
|
|||||||
bin
|
bin
|
||||||
*.exe
|
*.exe
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
|
||||||
Makefile
|
Makefile
|
||||||
|
|||||||
+202
@@ -5,6 +5,208 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 `<h1>` text; the backup page title is `SelfPost — backup &
|
||||||
|
migration` to reflect domain import as well as full backup. Operator guide and
|
||||||
|
architecture route tables updated.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- image: `mail.log` rotation no longer silently stops when the build context
|
||||||
|
ships `logrotate-mail.conf` with group/other write bits (common after a
|
||||||
|
Windows checkout sync). Runtime `COPY --chmod` pins config and script modes
|
||||||
|
in the Dockerfile; `logrotate-loop.sh` refuses a config logrotate would
|
||||||
|
ignore. E2e checks mode `644`, forced rotation, and a group-writable context
|
||||||
|
build.
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-08-12
|
||||||
|
|
||||||
|
Status page layout after 1.2.1: paired cards in a wide column, denser machine
|
||||||
|
and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: **Status** is wide again so paired cards fill the column. Layout:
|
||||||
|
Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖
|
||||||
|
Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS
|
||||||
|
certificate, and Hostname (and Hostname's trailing detail line); milter
|
||||||
|
socket paths omitted from the table; queue link reads **View queue**; milter
|
||||||
|
ok detail is `Listening` without a trailing period and sits in its own
|
||||||
|
Detail column beside the status badge; CPU detail is only core and thread
|
||||||
|
counts (no load average); memory detail is `N used of M` without the
|
||||||
|
«available to new work» clause; network detail lists per-interface totals
|
||||||
|
only (rates stay in the Usage column). No «On this page» section index —
|
||||||
|
the paired layout is short enough. Hostname stays in the polled fragment so
|
||||||
|
the pair survives HTMX refresh. In-panel docs for the removed blurbs filed
|
||||||
|
as roadmap `panel-docs`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
|
||||||
|
shrinks each card to its content and centres it in the track instead of
|
||||||
|
filling half the row. Side margins are cancelled for `.split > .card`
|
||||||
|
(Status, Settings, and a delivery's message/history).
|
||||||
|
- panel: on the user create/edit form, **Assigned domains** checkboxes stacked
|
||||||
|
the box above the domain name (and stretched it full-width) because the form's
|
||||||
|
block-label and full-width input rules applied to them. Checkbox rows now use
|
||||||
|
the shared `label.check` layout; the fieldset has matching spacing.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-08-11
|
||||||
|
|
||||||
|
Panel refinements after 1.2.0: navigation icons, status-badge centreing,
|
||||||
|
drill-down back-link placement, and safeguards for the sole global
|
||||||
|
administrator. Upgrading is a tag bump; no migration.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- panel: status badge text sat low in the box (and below the heading or label
|
||||||
|
beside it). IBM Plex Mono sits low in its em square; the previous top-heavy
|
||||||
|
padding made that worse. Bottom padding is now heavier so the word centres
|
||||||
|
optically.
|
||||||
|
- panel: the **Users** navigation icon was two full silhouettes with staggered
|
||||||
|
baselines, so the pair looked lopsided at 16 px. The rear person is now a
|
||||||
|
right-side crescent (head + shoulder) behind a full front silhouette aligned
|
||||||
|
with `icon-account`.
|
||||||
|
- panel: the **Settings** navigation icon was a sun-with-rays (circle plus
|
||||||
|
spokes), not a gear. It now uses a toothed cog so it matches the other
|
||||||
|
session icons and the 1.2.0 release note.
|
||||||
|
- panel: the user create/edit form placed «Back to users» at the bottom of the
|
||||||
|
card instead of under the heading like the delivery, domain, and domain-delete
|
||||||
|
pages. A shared `back_link` template now renders every drill-down up-link, and
|
||||||
|
`TestDrillDownPagesPlaceBackLinkAboveContent` guards its position.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: the user edit form disables role change and delete for the only global
|
||||||
|
administrator, with a short note, instead of allowing the action and showing
|
||||||
|
an error on submit.
|
||||||
|
- panel: the user create/edit form hides **Assigned domains** when the role is
|
||||||
|
global administrator, since that role manages every domain anyway.
|
||||||
|
- panel: **Settings** shows panel credentials and DMARC aggregate reports side
|
||||||
|
by side for global administrators (the same `.split` layout as a delivery's
|
||||||
|
message and history). Domain-scoped users keep the single narrow card.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-08-11
|
||||||
|
|
||||||
|
The second MINOR after 1.0.0: domain administrators with per-domain scope, a
|
||||||
|
panel visual refresh on the SelfPost palette, and refinements to navigation and
|
||||||
|
the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the
|
||||||
|
single administrator becomes a global user.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: **domain-admin role** — global administrators manage panel users and
|
||||||
|
assign domains; domain administrators see only their domains (applications,
|
||||||
|
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
|
||||||
|
backup, mail queue, system log, domain add/delete, and `/reload` stay
|
||||||
|
global-only. SQLite migration `0005_panel_users` migrates the single
|
||||||
|
administrator into a global user; sessions and full backup restore carry users
|
||||||
|
and domain bindings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
|
||||||
|
runtime image can copy it into `/usr/share/doc/selfpost/` as AGPL requires. A
|
||||||
|
clean build failed once the cached layer was invalidated.
|
||||||
|
- panel: on signed-in pages with a narrow card (**Settings**, the user form)
|
||||||
|
the heading, flash, card and footer now share one left edge. `.card.narrow`
|
||||||
|
had overridden only `max-width` while `main > *` still centred siblings on
|
||||||
|
the 48rem measure, so the card floated 12rem to the right of the title.
|
||||||
|
`main:has(> .card.narrow) > *` keeps the stack on 24rem without narrowing
|
||||||
|
the column, so the navigation stays put; login/setup are unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: navigation session icons are distinct — **Settings** uses a gear,
|
||||||
|
the signed-in user line carries the single-user icon, and **Users** a
|
||||||
|
two-person group mark instead of the same account silhouette for all three.
|
||||||
|
- panel: `/` redirects domain administrators to `/domains`; global users still
|
||||||
|
land on `/status`. Navigation hides global-only sections for domain
|
||||||
|
administrators.
|
||||||
|
- panel: **Sign out** in the navigation column uses the same type size and
|
||||||
|
weight as the page entries above it; only the red tint and border mark it as
|
||||||
|
destructive.
|
||||||
|
- panel: **visual style** brought in line with the SelfPost mark — brick accent
|
||||||
|
and warm paper in place of the blue-on-cool-grey defaults, IBM Plex Sans and
|
||||||
|
IBM Plex Mono served by the panel itself, squarer corners, and column
|
||||||
|
headings, status badges and small labels set in the mono face. Light and dark
|
||||||
|
schemes both keep their contrast; no page, control or workflow changed. The
|
||||||
|
send log stops breaking `Details` and `deferred` across two lines when a row
|
||||||
|
is tight. Badge padding and line-height are tuned so lowercase labels sit
|
||||||
|
centred in the box. The three WOFF2 files add ~76 KB to the image and are
|
||||||
|
served from the panel's own origin, so the Content-Security-Policy is
|
||||||
|
unchanged (`default-src 'self'`).
|
||||||
|
- panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
|
||||||
|
colours the status page and the DNS checks use, instead of the one place in
|
||||||
|
the panel where a status was bare text. The mapping is the one the delivery
|
||||||
|
page already applied — `sent` is ok, `deferred` a warning, `bounced` and
|
||||||
|
`rejected` errors, `queued` unknown because nothing has gone wrong yet.
|
||||||
|
- docs: [roadmap.md](docs/roadmap.md) and [product.md](docs/product.md) no
|
||||||
|
longer list domain-admin or visual-style as open work — both ship in this line.
|
||||||
|
Completed plan files (`domain-admin`, `visual-style`, `web-split`,
|
||||||
|
`narrow-page-alignment`) are removed; history stays in git and the entries
|
||||||
|
above. Inbound relay is the main agreed 1.x+ item left on the roadmap.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-10
|
## [1.1.0] - 2026-08-10
|
||||||
|
|
||||||
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
|
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
|
||||||
|
|||||||
@@ -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,
|
If you modify SelfPost and let users interact with it over a network,
|
||||||
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||||
your modified version. Update the Source link in the panel footer
|
your modified version. Update 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
|
Third-party software included in this distribution
|
||||||
----------------------------------------------------
|
----------------------------------------------------
|
||||||
|
|
||||||
htmx 2.0.4 (internal/web/static/htmx.min.js)
|
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||||
Copyright (c) Big Sky Software
|
Copyright (c) Big Sky Software
|
||||||
Licence: Zero-Clause BSD (0BSD)
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
https://github.com/bigskysoftware/htmx
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
IBM Plex Sans / IBM Plex Mono (wordmarks in SVG logos only)
|
IBM Plex Sans / IBM Plex Mono
|
||||||
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
Licence: SIL Open Font License 1.1
|
Licence: SIL Open Font License 1.1
|
||||||
The shipped SVGs use outlined paths derived from these fonts; the
|
https://github.com/IBM/plex
|
||||||
font files themselves are not distributed.
|
Distributed unmodified as three latin-subset WOFF2 files served by the
|
||||||
|
control panel:
|
||||||
|
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
|
||||||
|
internal/web/view/static/ibm-plex-mono-400.woff2
|
||||||
|
internal/web/view/static/ibm-plex-mono-600.woff2
|
||||||
|
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
|
||||||
|
the Google Fonts distribution. The wordmarks in the shipped SVG logos
|
||||||
|
are outlined paths derived from the same fonts.
|
||||||
|
|
||||||
Go module dependencies are listed with their licences in
|
Go module dependencies are listed with their licences in
|
||||||
docs/development.md § External libraries. Debian packages in the
|
docs/development.md § External libraries. Debian packages in the
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
||||||
- Two-level rate limiting — IP backstop (Postfix) and per-domain/per-app limits
|
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
|
||||||
- Full-server backup and single-domain export/import (optional password encryption)
|
- Full-server backup and single-domain export/import (optional password encryption)
|
||||||
- Single Docker image; data in a `./data` bind mount
|
- Single Docker image; data in a `./data` bind mount
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:1.1.0
|
ghcr.io/mixeme/selfpost:1.2.5
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
|
|||||||
+13
-13
@@ -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
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
# serves the same text at /license from an embedded copy.
|
# serves the same text at /license from an embedded copy.
|
||||||
COPY LICENSE NOTICE /usr/share/doc/selfpost/
|
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
|
|
||||||
COPY build/opendkim.conf /etc/opendkim.conf
|
# File modes are pinned here so a build context copied from a checkout without
|
||||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
|
||||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
# that logrotate would silently ignore — see docs/plans/logrotate-mode.md.
|
||||||
COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
|
||||||
COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
COPY build/crashexit.py /usr/local/bin/crashexit.py
|
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
|
||||||
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
|
||||||
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
|
||||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \
|
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
|
||||||
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
|
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
|
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
||||||
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
|
||||||
|
|||||||
+40
-1
@@ -14,10 +14,49 @@
|
|||||||
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
# often than daily — polling merely bounds how late a legitimate rotation runs.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
CONFIG=/etc/logrotate.d/mail
|
||||||
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
|
||||||
|
|
||||||
|
# logrotate refuses configs writable by group or others and exits 0 while
|
||||||
|
# ignoring them — fail here so supervisord reports the fault.
|
||||||
|
logrotate_config_ok() {
|
||||||
|
mode=$(stat -c '%a' "$CONFIG")
|
||||||
|
mode=${mode#0}
|
||||||
|
grp=$(( (mode / 10) % 10 ))
|
||||||
|
oth=$(( mode % 10 ))
|
||||||
|
case $grp in 2|3|6|7) return 1 ;; esac
|
||||||
|
case $oth in 2|3|6|7) return 1 ;; esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
logrotate_config_fatal() {
|
||||||
|
echo "logrotate-loop: refusing to run: $CONFIG mode $(stat -c '%a' "$CONFIG") is writable by group or others" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_logrotate() {
|
||||||
|
out=$(logrotate "$CONFIG" 2>&1) || {
|
||||||
|
echo "$out" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
case "$out" in
|
||||||
|
*Ignoring*|*Potentially\ dangerous\ mode*)
|
||||||
|
echo "$out" >&2
|
||||||
|
logrotate_config_fatal
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if logrotate /etc/logrotate.d/mail; then
|
if ! logrotate_config_ok; then
|
||||||
|
logrotate_config_fatal
|
||||||
|
fi
|
||||||
|
if run_logrotate; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
|
||||||
; in the image). Runs as root so logrotate can read/rotate the log; never exits
|
; in the image). Runs as root so logrotate can read/rotate the log. Exits
|
||||||
; non-zero, so it neither trips the crashexit listener nor needs restarting.
|
; non-zero when the config is group/other-writable (logrotate would ignore it
|
||||||
|
; silently); autorestart surfaces BACKOFF on the Status page.
|
||||||
[program:logrotate]
|
[program:logrotate]
|
||||||
command=/usr/local/bin/logrotate-loop.sh
|
command=/usr/local/bin/logrotate-loop.sh
|
||||||
priority=400
|
priority=400
|
||||||
|
|||||||
+15
-13
@@ -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)
|
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||||
|
|
||||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
MailLogPath: cfg.mailLog,
|
MailLogPath: cfg.mailLog,
|
||||||
DataDir: cfg.dataDir,
|
DataDir: cfg.dataDir,
|
||||||
DBPath: cfg.dbPath,
|
DBPath: cfg.dbPath,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
OpenDKIMSocket: cfg.opendkimSocket,
|
OpenDKIMSocket: cfg.opendkimSocket,
|
||||||
JournalSocket: cfg.journalSocket,
|
JournalSocket: cfg.journalSocket,
|
||||||
SessionIdleDays: cfg.sessionIdleDays,
|
SessionIdleDays: cfg.sessionIdleDays,
|
||||||
DNSResolvers: cfg.dnsResolvers,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ type config struct {
|
|||||||
trustedProxies []*net.IPNet
|
trustedProxies []*net.IPNet
|
||||||
sessionIdleDays int
|
sessionIdleDays int
|
||||||
dnsResolvers []string
|
dnsResolvers []string
|
||||||
|
rateLimitMessagesPerIP int
|
||||||
|
rateLimitWindowSeconds int
|
||||||
|
|
||||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||||
// serves and the two milter sockets it connects to. The defaults mirror
|
// serves and the two milter sockets it connects to. The defaults mirror
|
||||||
@@ -117,6 +119,10 @@ func loadConfig() config {
|
|||||||
// means dnscheck's public defaults; a closed network names its own here.
|
// means dnscheck's public defaults; a closed network names its own here.
|
||||||
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
|
||||||
|
|
||||||
|
// Level-1 anvil defaults match build/postfix-config.sh / guide.md.
|
||||||
|
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
|
||||||
|
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
|
||||||
|
|
||||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.1.0
|
image: ghcr.io/mixeme/selfpost:1.2.5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -81,11 +81,13 @@ One process, three roles:
|
|||||||
proxy only.
|
proxy only.
|
||||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||||
(`default_action=accept`) so milter failure does not stop mail. The level-2
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
count is the stored send-log rows plus the messages this process has admitted
|
ceilings apply to every client IP; an application ceiling with trusted IPs
|
||||||
but not yet written (`internal/milter/inflight.go`), so concurrent sessions
|
raises the limit for those IPs only and skips the domain check (guide § Rate
|
||||||
cannot each spend the same last slot; a reservation is released at
|
limiting). The level-2 count is the stored send-log rows plus the messages
|
||||||
end-of-message, on ABORT, or after a 10-minute TTL.
|
this process has admitted but not yet written (`internal/milter/inflight.go`),
|
||||||
|
so concurrent sessions cannot each spend the same last slot; a reservation
|
||||||
|
is released at end-of-message, on ABORT, or after a 10-minute TTL.
|
||||||
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
||||||
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
||||||
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
||||||
@@ -170,7 +172,8 @@ unless noted. The table below is a summary — HTMX fragment endpoints
|
|||||||
| `/system-log` | `mail.log` tail |
|
| `/system-log` | `mail.log` tail |
|
||||||
| `/reload` | Reload OpenDKIM + Postfix maps |
|
| `/reload` | Reload OpenDKIM + Postfix maps |
|
||||||
| `/backup` | Full backup download (page also hosts the import form) |
|
| `/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
|
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
|
||||||
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||||
|
|||||||
+5
-3
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||||
make build VERSION=1.1.0
|
make build VERSION=1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
@@ -162,8 +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
|
The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a
|
||||||
runtime stage (Debian + mail stack). See [architecture.md](architecture.md) §
|
runtime stage (Debian + mail stack). Runtime config and scripts use `COPY
|
||||||
Image and processes.
|
--chmod` so file modes in the image do not depend on how the build context was
|
||||||
|
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
|
||||||
|
See [architecture.md](architecture.md) § Image and processes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+22
-13
@@ -189,7 +189,7 @@ service healthy and will mail be accepted?"
|
|||||||
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
|
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
|
||||||
TLS certificate validity and expiry, milter socket presence, and a short
|
TLS certificate validity and expiry, milter socket presence, and a short
|
||||||
Postfix queue summary. The **Machine** card adds the resource usage of the
|
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
|
per-interface network throughput and totals — read from the kernel's
|
||||||
counters; CPU and throughput are measured between refreshes, so they appear
|
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
|
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`.
|
shows after manual edits under `/data`.
|
||||||
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
|
||||||
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
|
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.
|
*Import a domain* on the Backup page reads one back in.
|
||||||
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
|
||||||
by domain and application. A row identifies its message and nothing more —
|
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
|
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
||||||
the domain-import form (`POST /domains/import`). See
|
the domain-import form (`POST /domains/import`). See
|
||||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
[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.
|
Application SASL logins are separate and are not changed here.
|
||||||
|
|
||||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
**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
|
## Rate limiting
|
||||||
|
|
||||||
SelfPost applies two independent limits; both can refuse a submission, but only
|
SelfPost applies two independent layers; both can refuse a submission, but only
|
||||||
level 2 writes a `rejected` row in the send log.
|
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`:
|
**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`
|
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
|
||||||
|
|
||||||
This is an anvil limit per connecting client IP. It keeps working even if the
|
This is an anvil limit per connecting client IP. It keeps working even if the
|
||||||
journal-milter (level 2) is down.
|
journal-milter (level 2) is down. There is no per-IP bypass.
|
||||||
|
|
||||||
**Level 2 (per domain / per application)** — optional, configured in the panel
|
**Level 2 — domain** — optional, on each domain's page. A message ceiling and
|
||||||
on each domain's page or on an individual application. You set a message
|
window for **every** client IP sending as that domain. When unset, only
|
||||||
ceiling, a time window, and optionally restrict the limit to specific client
|
level 1 applies for non-privileged senders.
|
||||||
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
|
**Level 2 — application (trusted IPs)** — optional override on an application:
|
||||||
`rejected`.
|
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
|
## 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
|
## Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`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
|
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:
|
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
|
the panel binary's embedded version and the image tag that produced it are the
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
**Status:** agreed
|
**Status:** agreed
|
||||||
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
||||||
the implementation lands (do not fix a major in advance).
|
the implementation lands (do not fix a major in advance).
|
||||||
**Order:** recommended after [web-split](web-split.md) and
|
**Order:** recommended next among agreed 1.x+ items (after `internal/web` split
|
||||||
[domain-admin](domain-admin.md).
|
and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md)
|
||||||
|
`[1.2.0]`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Plan: web-split (splitting `internal/web`)
|
|
||||||
|
|
||||||
**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`)
|
|
||||||
**Version:** `1.x`; an internal refactor, it does not force a break on its own.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
`internal/web` is the project's largest package: ~50 files (templates and
|
|
||||||
static assets included), ~25 `.go` files and ~4000 lines of Go, with the
|
|
||||||
handlers for every panel section, sessions, security headers, origin checking,
|
|
||||||
form validation and template rendering all sitting in one flat namespace.
|
|
||||||
|
|
||||||
The candidates to split out are `web/handlers` and `web/auth`, or a cut along
|
|
||||||
the panel's own domains.
|
|
||||||
|
|
||||||
## Why now
|
|
||||||
|
|
||||||
At its current size the flat package reads fine: the file names
|
|
||||||
(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
|
|
||||||
directories would, and splitting would force exporting what is package-private
|
|
||||||
today — widening the internal API for cosmetics.
|
|
||||||
|
|
||||||
It starts to pay off once the package grows: **domain-admin** and
|
|
||||||
**inbound-relay** both add code to it — the role brings authorisation into
|
|
||||||
every handler, the inbound relay brings its own pages and handlers for inbound
|
|
||||||
domains. The refactor is cheaper before that growth than after it.
|
|
||||||
|
|
||||||
## Recommended order
|
|
||||||
|
|
||||||
**web-split → domain-admin → inbound-relay** (see the
|
|
||||||
[roadmap](../roadmap.md)).
|
|
||||||
|
|
||||||
1. **web-split** — lay down the package structure (including a place for
|
|
||||||
`web/auth`) while there are no cross-cutting edits from the role and no new
|
|
||||||
inbound handlers.
|
|
||||||
2. **domain-admin** — authorisation in every handler builds on a package layout
|
|
||||||
already chosen.
|
|
||||||
3. **inbound-relay** — a new vertical slice; easier to add to an already split
|
|
||||||
package than to refactor alongside the two features before it.
|
|
||||||
|
|
||||||
The order is a recommendation, not a blocker.
|
|
||||||
|
|
||||||
## Chosen scheme
|
|
||||||
|
|
||||||
**Horizontal split into four packages** (decided at implementation):
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/web/ # Config, Server, New, Handler — composition root; security.go
|
|
||||||
internal/web/view/ # embed templates/static, render/renderFragment, staticHandler
|
|
||||||
internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser
|
|
||||||
internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle)
|
|
||||||
internal/web/handlers/ # all authenticated page handlers (handlers_*.go)
|
|
||||||
```
|
|
||||||
|
|
||||||
`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported
|
|
||||||
beyond what the composition root needs.
|
|
||||||
|
|
||||||
## Done when
|
|
||||||
|
|
||||||
The package is split along the scheme above. After the split: `build`/`vet`/`test`
|
|
||||||
green, the panel's behaviour unchanged.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Splitting too early — a superfluous internal API and churn with nothing to
|
|
||||||
show for it;
|
|
||||||
- leaving it until after the growth — a harder refactor, tangled up with the
|
|
||||||
features.
|
|
||||||
+12
-9
@@ -47,17 +47,19 @@ Explicitly excluded to prevent scope creep:
|
|||||||
|
|
||||||
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
|
||||||
- Webmail
|
- Webmail
|
||||||
- Multi-user panel / organisations / roles — one administrator; managing
|
- Organisations / tenancy beyond global + domain-admin roles; managing
|
||||||
**multiple sending domains** is in scope (see below)
|
**multiple sending domains** under one global administrator is in scope (see
|
||||||
|
below)
|
||||||
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
|
||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
The **domain-admin** role ships in the current line (global administrator plus
|
||||||
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
domain administrators with assigned domains). The optional **inbound relay** is
|
||||||
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
the main agreed **1.x+** extension still on the
|
||||||
implementation. Items still marked *candidate* in the roadmap require explicit
|
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
|
||||||
approval before coding.
|
remains possible pending implementation. Items marked *candidate* in the
|
||||||
|
roadmap require explicit approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,5 +104,6 @@ Adding a domain does **not** create an application automatically.
|
|||||||
- **Delete domain** — removes DKIM key and **all** its applications.
|
- **Delete domain** — removes DKIM key and **all** its applications.
|
||||||
- **Delete application** — removes only that app's SASL and map entries.
|
- **Delete application** — removes only that app's SASL and map entries.
|
||||||
|
|
||||||
This is not multi-tenancy (one admin); it is one owner operating several
|
This is not multi-tenancy; it is one owner (or a small team with global and
|
||||||
sending domains with independent application credentials.
|
domain-scoped roles) operating several sending domains with independent
|
||||||
|
application credentials.
|
||||||
|
|||||||
+28
-49
@@ -26,15 +26,14 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
|
|
||||||
| ID | Topic | Status | Plan |
|
| ID | Topic | Status | Plan |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
|
|
||||||
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — |
|
| contributing | `CONTRIBUTING.md` | candidate | — |
|
||||||
| visual-style | Обновление визуального стиля | candidate | — |
|
|
||||||
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||||
|
| panel-docs | In-panel operator documentation | candidate | — |
|
||||||
|
|
||||||
**Recommended order** (not binding): **domain-admin →
|
**Recommended order** (not binding): **inbound-relay** first among agreed
|
||||||
inbound-relay** — role-wide authorisation first, then the inbound relay vertical
|
items — it is the largest remaining 1.x+ extension. Candidates need explicit
|
||||||
slice. Deviating is allowed; there are no hard phases here.
|
agreement before they join the queue.
|
||||||
|
|
||||||
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
the checklist in its linked plan.
|
the checklist in its linked plan.
|
||||||
@@ -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;
|
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
||||||
a wider attack surface (port 25 accepting mail).
|
a wider attack surface (port 25 accepting mail).
|
||||||
**Order:** recommended after [domain-admin](plans/domain-admin.md).
|
|
||||||
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
||||||
implementation lands.
|
implementation lands.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## domain-admin
|
|
||||||
|
|
||||||
**Goal:** a role with access to one or several assigned domains (the list is
|
|
||||||
set by the global administrator) — applications, DKIM/DNS, and the send log for
|
|
||||||
each of them; without global operations (adding domains, full backup, the
|
|
||||||
queue, `mail.log`).
|
|
||||||
|
|
||||||
**Boundary:** an extension of v1.0 — [product.md](product.md) fixes a single
|
|
||||||
administrator. Not a second all-powerful admin, but limited access to the
|
|
||||||
assigned domains (one or several).
|
|
||||||
|
|
||||||
**Done when:** see [plans/domain-admin.md](plans/domain-admin.md).
|
|
||||||
|
|
||||||
**Dependencies / risks:** a users table, the role in the session, authorisation
|
|
||||||
in every handler, setup and backup. **Order:** recommended **before**
|
|
||||||
[inbound-relay](plans/inbound-relay.md).
|
|
||||||
**Version:** `1.x` MINOR, given a compatible migration of the current
|
|
||||||
administrator into a global one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## contributing
|
## contributing
|
||||||
|
|
||||||
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
@@ -102,28 +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
|
## dmarc-reports
|
||||||
|
|
||||||
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
|||||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
|
||||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
// limiting). The caller has validated the IPs and numbers (security.md); the
|
||||||
// is needed.
|
// milter reads the row live, so no reload is needed.
|
||||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeApp,
|
Scope: store.RateLimitScopeApp,
|
||||||
|
|||||||
@@ -145,13 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||||
// IPs and numbers (security.md); the milter reads the row live, so no reload is
|
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||||
// needed.
|
// Domain limits do not use an IP allowlist.
|
||||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
Scope: store.RateLimitScopeDomain,
|
Scope: store.RateLimitScopeDomain,
|
||||||
RefID: domainID,
|
RefID: domainID,
|
||||||
AllowedIPs: ips,
|
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
|
||||||
MaxMessages: maxMessages,
|
MaxMessages: maxMessages,
|
||||||
WindowSeconds: windowSeconds,
|
WindowSeconds: windowSeconds,
|
||||||
})
|
})
|
||||||
|
|||||||
+101
-36
@@ -62,10 +62,14 @@ type CPU struct {
|
|||||||
// nothing until it is true.
|
// nothing until it is true.
|
||||||
Measured bool
|
Measured bool
|
||||||
BusyPct float64
|
BusyPct float64
|
||||||
Cores int
|
// Cores and Threads are physical cores and logical CPUs (SMT). Both come
|
||||||
|
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
|
||||||
|
// the cpuN lines in /proc/stat and Cores matches Threads.
|
||||||
|
Cores int
|
||||||
|
Threads int
|
||||||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
||||||
// could be read. Unlike BusyPct it needs no previous sample, so it is
|
// could be read. Unlike BusyPct it needs no previous sample. It is kept
|
||||||
// there on the very first page load.
|
// for the sampler but is no longer shown on the Status card.
|
||||||
Load [3]float64
|
Load [3]float64
|
||||||
HasLoad bool
|
HasLoad bool
|
||||||
Status Status
|
Status Status
|
||||||
@@ -176,7 +180,14 @@ type netCounters struct {
|
|||||||
func (m *MachineSampler) Sample() Machine {
|
func (m *MachineSampler) Sample() Machine {
|
||||||
root := m.root()
|
root := m.root()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
cpuNow, logical, cpuErr := readCPUTimes(root)
|
||||||
|
cores, threads := readCPUTopology(root)
|
||||||
|
if threads == 0 {
|
||||||
|
threads = logical
|
||||||
|
}
|
||||||
|
if cores == 0 {
|
||||||
|
cores = threads
|
||||||
|
}
|
||||||
netNow, netErr := readNetDev(root)
|
netNow, netErr := readNetDev(root)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine {
|
|||||||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||||||
|
|
||||||
mach := Machine{
|
mach := Machine{
|
||||||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
|
||||||
Memory: readMemory(root),
|
Memory: readMemory(root),
|
||||||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||||||
}
|
}
|
||||||
@@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine {
|
|||||||
return mach
|
return mach
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuUsage grades the processor over the window. The load average is reported
|
// cpuUsage grades the processor over the window. Detail carries only the
|
||||||
// alongside it because the two answer different questions — how busy the
|
// core and thread counts — load average and busy prose stay out of the Status
|
||||||
// processor was, and how many tasks were waiting for it — and a machine can
|
// card (see roadmap panel-docs for operator-facing explanation later).
|
||||||
// look idle while work queues up behind a slow disk.
|
func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
|
||||||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
c := CPU{Cores: cores, Threads: threads}
|
||||||
c := CPU{Cores: cores}
|
|
||||||
if !isZeroLoad(load) {
|
if !isZeroLoad(load) {
|
||||||
c.Load, c.HasLoad = load, true
|
c.Load, c.HasLoad = load, true
|
||||||
}
|
}
|
||||||
@@ -223,9 +233,9 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
|||||||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
c.Detail = c.contextText()
|
||||||
if !fresh || cur.total <= prev.total {
|
if !fresh || cur.total <= prev.total {
|
||||||
c.Status = StatusUnknown
|
c.Status = StatusUnknown
|
||||||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
|||||||
|
|
||||||
if c.BusyPct >= cpuWarnPct {
|
if c.BusyPct >= cpuWarnPct {
|
||||||
c.Status = StatusWarn
|
c.Status = StatusWarn
|
||||||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
|
||||||
} else {
|
} else {
|
||||||
c.Status = StatusOK
|
c.Status = StatusOK
|
||||||
c.Detail = c.contextText()
|
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextText is the CPU's supporting figures: what the percentage is a
|
// contextText is the CPU detail column: physical cores and logical threads.
|
||||||
// percentage of, and how deep the run queue is.
|
|
||||||
func (c CPU) contextText() string {
|
func (c CPU) contextText() string {
|
||||||
var parts []string
|
switch {
|
||||||
if c.Cores > 0 {
|
case c.Cores > 0 && c.Threads > 0:
|
||||||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
|
||||||
|
case c.Threads > 0:
|
||||||
|
return fmt.Sprintf("%d threads", c.Threads)
|
||||||
|
case c.Cores > 0:
|
||||||
|
return fmt.Sprintf("%d cores", c.Cores)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if c.HasLoad {
|
|
||||||
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||||||
@@ -296,8 +305,7 @@ func readMemory(root string) Memory {
|
|||||||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||||||
}
|
}
|
||||||
|
|
||||||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
|
||||||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
|
||||||
if m.SwapTotalBytes > 0 {
|
if m.SwapTotalBytes > 0 {
|
||||||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||||||
}
|
}
|
||||||
@@ -418,6 +426,74 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
|
|||||||
return times, cores, nil
|
return times, cores, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCPUTopology returns physical core and logical thread counts from
|
||||||
|
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
|
||||||
|
// back to the cpuN count from /proc/stat.
|
||||||
|
func readCPUTopology(root string) (cores, threads int) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
type coreKey struct{ phys, core int }
|
||||||
|
seen := map[coreKey]bool{}
|
||||||
|
var (
|
||||||
|
inCPU bool
|
||||||
|
phys = -1
|
||||||
|
core = -1
|
||||||
|
idx int
|
||||||
|
)
|
||||||
|
flush := func() {
|
||||||
|
if !inCPU {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, c := phys, core
|
||||||
|
if p < 0 {
|
||||||
|
p = idx
|
||||||
|
}
|
||||||
|
if c < 0 {
|
||||||
|
c = idx
|
||||||
|
}
|
||||||
|
seen[coreKey{p, c}] = true
|
||||||
|
inCPU, phys, core = false, -1, -1
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
switch key {
|
||||||
|
case "processor":
|
||||||
|
flush()
|
||||||
|
inCPU = true
|
||||||
|
threads++
|
||||||
|
idx = threads - 1
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
idx = n
|
||||||
|
}
|
||||||
|
case "physical id":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
phys = n
|
||||||
|
}
|
||||||
|
case "core id":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
core = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if threads == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return len(seen), threads
|
||||||
|
}
|
||||||
|
|
||||||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
||||||
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||||||
// than an error.
|
// than an error.
|
||||||
@@ -544,14 +620,3 @@ func humanRate(perSec float64) string {
|
|||||||
}
|
}
|
||||||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||||||
}
|
}
|
||||||
|
|
||||||
// joinDetail joins the non-empty parts of a detail line.
|
|
||||||
func joinDetail(parts ...string) string {
|
|
||||||
var kept []string
|
|
||||||
for _, p := range parts {
|
|
||||||
if p != "" {
|
|
||||||
kept = append(kept, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(kept, " ")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
|||||||
// 1000 ticks pass, 250 of them idle: 75% busy.
|
// 1000 ticks pass, 250 of them idle: 75% busy.
|
||||||
prev := cpuTimes{total: 10000, idle: 8000}
|
prev := cpuTimes{total: 10000, idle: 8000}
|
||||||
cur := cpuTimes{total: 11000, idle: 8250}
|
cur := cpuTimes{total: 11000, idle: 8250}
|
||||||
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||||
if !got.Measured {
|
if !got.Measured {
|
||||||
t.Fatalf("reading not marked measured: %+v", got)
|
t.Fatalf("reading not marked measured: %+v", got)
|
||||||
}
|
}
|
||||||
@@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
|||||||
if got.Status != StatusOK {
|
if got.Status != StatusOK {
|
||||||
t.Errorf("status = %q, want ok", got.Status)
|
t.Errorf("status = %q, want ok", got.Status)
|
||||||
}
|
}
|
||||||
if got.Cores != 4 || !got.HasLoad {
|
if got.Cores != 4 || got.Threads != 8 || !got.HasLoad {
|
||||||
t.Errorf("cores/load not reported: %+v", got)
|
t.Errorf("cores/threads/load not reported: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Detail != "4 cores · 8 threads" {
|
||||||
|
t.Errorf("detail = %q, want cores and threads only", got.Detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
||||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
|
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, 1, [3]float64{}, true, nil)
|
||||||
if got.Status != StatusWarn {
|
if got.Status != StatusWarn {
|
||||||
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
||||||
}
|
}
|
||||||
|
if got.Detail != "1 cores · 1 threads" {
|
||||||
|
t.Errorf("detail = %q, want topology only (no warn prose)", got.Detail)
|
||||||
|
}
|
||||||
if got.HasLoad {
|
if got.HasLoad {
|
||||||
t.Error("a missing load average should not be reported as zeros")
|
t.Error("a missing load average should not be reported as zeros")
|
||||||
}
|
}
|
||||||
@@ -84,17 +90,44 @@ func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
|
|||||||
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
||||||
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
||||||
} {
|
} {
|
||||||
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
|
got := cpuUsage(c.prev, c.cur, 2, 2, [3]float64{}, c.fresh, nil)
|
||||||
if got.Measured || got.Status != StatusUnknown {
|
if got.Measured || got.Status != StatusUnknown {
|
||||||
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||||
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadCPUTopology(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
const cpuinfo = `processor : 0
|
||||||
|
physical id : 0
|
||||||
|
core id : 0
|
||||||
|
|
||||||
|
processor : 1
|
||||||
|
physical id : 0
|
||||||
|
core id : 0
|
||||||
|
|
||||||
|
processor : 2
|
||||||
|
physical id : 0
|
||||||
|
core id : 1
|
||||||
|
|
||||||
|
processor : 3
|
||||||
|
physical id : 0
|
||||||
|
core id : 1
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cpuinfo"), []byte(cpuinfo), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cores, threads := readCPUTopology(dir)
|
||||||
|
if cores != 2 || threads != 4 {
|
||||||
|
t.Errorf("topology = %d cores / %d threads, want 2/4", cores, threads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadMemory(t *testing.T) {
|
func TestReadMemory(t *testing.T) {
|
||||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
||||||
got := readMemory(dir)
|
got := readMemory(dir)
|
||||||
@@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) {
|
|||||||
if got.TotalBytes != 4*1024*1024*1024 {
|
if got.TotalBytes != 4*1024*1024*1024 {
|
||||||
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
||||||
}
|
}
|
||||||
// 4 GiB total, 2 GiB available to new work: half used, cache included in
|
// 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available).
|
||||||
// what is available.
|
|
||||||
if got.Percent() != 50 {
|
if got.Percent() != 50 {
|
||||||
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
||||||
}
|
}
|
||||||
@@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
|
|||||||
if !first.Memory.Measured {
|
if !first.Memory.Measured {
|
||||||
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
||||||
}
|
}
|
||||||
if first.CPU.Cores != 2 {
|
if first.CPU.Cores != 2 || first.CPU.Threads != 2 {
|
||||||
t.Errorf("cores = %d, want 2", first.CPU.Cores)
|
t.Errorf("cores/threads = %d/%d, want 2/2", first.CPU.Cores, first.CPU.Threads)
|
||||||
|
}
|
||||||
|
if first.CPU.Detail != "2 cores · 2 threads" {
|
||||||
|
t.Errorf("detail = %q, want topology only", first.CPU.Detail)
|
||||||
}
|
}
|
||||||
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
||||||
t.Errorf("load average not read: %+v", first.CPU.Load)
|
t.Errorf("load average not read: %+v", first.CPU.Load)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket {
|
|||||||
default:
|
default:
|
||||||
s.Present = true
|
s.Present = true
|
||||||
s.Status = StatusOK
|
s.Status = StatusOK
|
||||||
s.Detail = "Listening."
|
s.Detail = "Listening"
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
|
|||||||
return store.RateLimit{}, false, f.lookupErr
|
return store.RateLimit{}, false, f.lookupErr
|
||||||
}
|
}
|
||||||
rl, ok := f.limits[scope+"|"+ref]
|
rl, ok := f.limits[scope+"|"+ref]
|
||||||
|
if ok {
|
||||||
|
rl.Scope = scope
|
||||||
|
}
|
||||||
return rl, ok, nil
|
return rl, ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +189,14 @@ func TestBracedMacros(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
// limitIP is the client IP rate-limit tests connect from.
|
||||||
// register it so the differentiated check applies.
|
|
||||||
const limitIP = "203.0.113.7"
|
const limitIP = "203.0.113.7"
|
||||||
|
|
||||||
func activeLimit(ips ...string) store.RateLimit {
|
func domainLimit() store.RateLimit {
|
||||||
|
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appLimit(ips ...string) store.RateLimit {
|
||||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
|||||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||||
}
|
}
|
||||||
@@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||||
}
|
}
|
||||||
@@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||||
}
|
}
|
||||||
@@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
// The sender's IP is not in the domain's registered set, so level-2 does not
|
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
||||||
// apply even though the count is huge (level-1 anvil would still cover it).
|
// are ignored.
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
||||||
|
store.RateLimitScopeApp + "|app1": 2, // under app
|
||||||
|
},
|
||||||
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {
|
||||||
|
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
|
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
|
||||||
|
},
|
||||||
|
counts: map[string]int64{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": 1,
|
||||||
|
store.RateLimitScopeApp + "|app1": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
|||||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
countErr: errors.New("db down"),
|
countErr: errors.New("db down"),
|
||||||
}
|
}
|
||||||
@@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
|||||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
@@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
|
|||||||
func limitedRecorder(count int64) *fakeRecorder {
|
func limitedRecorder(count int64) *fakeRecorder {
|
||||||
return &fakeRecorder{
|
return &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
||||||
}
|
}
|
||||||
@@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A refused message must not leave the slots it claimed for the limits checked
|
// A trusted app at its ceiling refuses without touching the domain counter;
|
||||||
// before the one that tripped, or every refusal would tighten the ceiling.
|
// no domain reservation should linger after the refusal.
|
||||||
func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
counts: map[string]int64{
|
||||||
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
|
store.RateLimitScopeDomain + "|example.com": 0,
|
||||||
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
|
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
fl := &inflight{}
|
fl := &inflight{}
|
||||||
@@ -415,6 +472,9 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
|||||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
||||||
}
|
}
|
||||||
|
if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("app reservation left behind after refusal: %d", n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The in-flight count only covers the limit's own window: a reservation older
|
// The in-flight count only covers the limit's own window: a reservation older
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// overLimit reports whether the message currently being received should be
|
// overLimit reports whether the message currently being received should be
|
||||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
||||||
// checks the domain-level and application-level limits in turn; either being
|
//
|
||||||
// exceeded is enough to refuse.
|
// Trusted application IPs (app limit active and client IP listed) use only the
|
||||||
|
// app ceiling and skip the domain check. Everyone else is under the domain
|
||||||
|
// ceiling when one is configured; otherwise only level 1 applies.
|
||||||
//
|
//
|
||||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||||
@@ -26,46 +28,48 @@ func (s *session) overLimit() bool {
|
|||||||
if s.clientIP == "" {
|
if s.clientIP == "" {
|
||||||
return false // no client IP to key on; level-2 does not apply
|
return false // no client IP to key on; level-2 does not apply
|
||||||
}
|
}
|
||||||
checks := []struct{ scope, ref string }{
|
|
||||||
{store.RateLimitScopeDomain, domainOf(s.from)},
|
if s.login != "" {
|
||||||
{store.RateLimitScopeApp, s.login},
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||||
}
|
|
||||||
var taken []*reservation
|
|
||||||
for _, c := range checks {
|
|
||||||
if c.ref == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
||||||
continue
|
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
|
||||||
|
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
||||||
}
|
}
|
||||||
// No limit configured, an inert draft, or a client IP outside the
|
|
||||||
// registered set: the differentiated limit does not apply here.
|
|
||||||
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
|
||||||
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := c.scope + "|" + c.ref
|
|
||||||
n += s.flight.count(key, since)
|
|
||||||
if n >= int64(rl.MaxMessages) {
|
|
||||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
|
||||||
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
|
||||||
// The message is refused, so the slots claimed for the limits
|
|
||||||
// checked before this one must not stay claimed.
|
|
||||||
for _, r := range taken {
|
|
||||||
s.flight.release(r)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
taken = append(taken, s.flight.reserve(key))
|
|
||||||
}
|
}
|
||||||
s.reserved = append(s.reserved, taken...)
|
|
||||||
|
domain := domainOf(s.from)
|
||||||
|
if domain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !ok || !rl.Active() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.enforceLimit(store.RateLimitScopeDomain, domain, rl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceLimit counts recent messages for scope/ref and refuses when at or
|
||||||
|
// above the ceiling. 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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -18,36 +18,40 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
||||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
// a message ceiling over a sliding window, attached to a domain or an
|
||||||
// window, attached to a domain or an application. It is enforced in the
|
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
|
||||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
// architecture.md § Mail path) is the IP backstop that always applies even
|
||||||
// IP backstop that always applies even when this is absent or the milter is
|
// when this is absent or the milter is down.
|
||||||
// down.
|
|
||||||
//
|
//
|
||||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
// Domain limits apply to every client IP once max and window are set. Application
|
||||||
// is only enforced when it is Active(): the design deliberately allows an
|
// limits additionally require AllowedIPs: those trusted addresses get the app
|
||||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
// ceiling (above the domain) and skip the domain check; other IPs stay under
|
||||||
// which case only level 1 protects them (guide § Rate limiting).
|
// the domain limit or level 1 alone (guide § Rate limiting).
|
||||||
type RateLimit struct {
|
type RateLimit struct {
|
||||||
Scope string
|
Scope string
|
||||||
RefID int64
|
RefID int64
|
||||||
AllowedIPs []string // canonical client IPs this limit applies to
|
AllowedIPs []string // trusted client IPs for an application override
|
||||||
MaxMessages int
|
MaxMessages int
|
||||||
WindowSeconds int
|
WindowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active reports whether the limit is fully configured and should be enforced.
|
// Active reports whether the limit is fully configured and should be enforced.
|
||||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
// Domain: max and window only. Application: also needs at least one trusted IP
|
||||||
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
|
// (the privilege that raises the ceiling above the domain).
|
||||||
// known sender.
|
|
||||||
func (r RateLimit) Active() bool {
|
func (r RateLimit) Active() bool {
|
||||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Scope == RateLimitScopeApp {
|
||||||
|
return len(r.AllowedIPs) > 0
|
||||||
|
}
|
||||||
|
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
// AllowsIP reports whether ip is one of the application's trusted client IPs.
|
||||||
// comparison parses both sides so equivalent textual forms of the same address
|
// Used only for application overrides; domain limits do not consult this list.
|
||||||
// match; a client IP outside the list means the differentiated limit does not
|
// Equivalent textual forms of the same address match.
|
||||||
// apply to it (level 1 still does).
|
|
||||||
func (r RateLimit) AllowsIP(ip string) bool {
|
func (r RateLimit) AllowsIP(ip string) bool {
|
||||||
c := net.ParseIP(ip)
|
c := net.ParseIP(ip)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||||
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
// by leaving the corresponding field zero, which makes the limit inert via
|
||||||
// limit inert via Active().
|
// Active() until max and window are both set.
|
||||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||||
var (
|
var (
|
||||||
ips sql.NullString
|
ips sql.NullString
|
||||||
|
|||||||
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
|||||||
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||||
inactive := []RateLimit{
|
inactive := []RateLimit{
|
||||||
{},
|
{},
|
||||||
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||||
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||||
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
|
||||||
|
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||||
}
|
}
|
||||||
for i, rl := range inactive {
|
for i, rl := range inactive {
|
||||||
if rl.Active() {
|
if rl.Active() {
|
||||||
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
|
||||||
if !active.Active() {
|
if !domainActive.Active() {
|
||||||
t.Fatalf("should be active: %+v", active)
|
t.Fatalf("domain without IPs should be active: %+v", domainActive)
|
||||||
}
|
}
|
||||||
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
appActive := RateLimit{
|
||||||
|
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
|
||||||
|
MaxMessages: 5, WindowSeconds: 60,
|
||||||
|
}
|
||||||
|
if !appActive.Active() {
|
||||||
|
t.Fatalf("should be active: %+v", appActive)
|
||||||
|
}
|
||||||
|
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
|
||||||
t.Fatalf("registered IPs should match")
|
t.Fatalf("registered IPs should match")
|
||||||
}
|
}
|
||||||
// Equivalent textual form of the IPv6 address must still match.
|
// Equivalent textual form of the IPv6 address must still match.
|
||||||
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||||
t.Fatalf("expanded IPv6 form should match")
|
t.Fatalf("expanded IPv6 form should match")
|
||||||
}
|
}
|
||||||
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
|
||||||
t.Fatalf("unregistered/invalid IPs must not match")
|
t.Fatalf("unregistered/invalid IPs must not match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
|||||||
|
|
||||||
// HandleLogin serves the login form (GET) and authenticates (POST).
|
// HandleLogin serves the login form (GET) and authenticates (POST).
|
||||||
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
exists, err := m.store.AdminExists()
|
exists, err := m.store.UserExists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -106,22 +106,21 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
password := r.PostFormValue("password")
|
password := r.PostFormValue("password")
|
||||||
|
|
||||||
admin, err := m.store.GetAdmin()
|
user, err := m.store.GetUserByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, store.ErrNoAdmin) {
|
if !errors.Is(err, store.ErrUserNotFound) {
|
||||||
logf("panel: login: get admin failed: %v", err)
|
logf("panel: login: get user failed: %v", err)
|
||||||
}
|
}
|
||||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
if username != admin.Username || pwErr != nil {
|
|
||||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := m.sessions.Create(admin.Username)
|
token := m.sessions.Create(user.Username)
|
||||||
m.setSessionCookie(w, token)
|
m.setSessionCookie(w, token)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -222,13 +221,13 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.CreateAdmin(username, string(hash)); err != nil {
|
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
|
||||||
if exists, _ := m.store.AdminExists(); exists {
|
if exists, _ := m.store.UserExists(); exists {
|
||||||
m.setup.complete()
|
m.setup.complete()
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
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.")
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const usernameKey ctxKey = 0
|
|
||||||
|
|
||||||
// RequireAuth wraps a handler so only requests with a valid session cookie
|
// RequireAuth wraps a handler so only requests with a valid session cookie
|
||||||
// reach it; everyone else is redirected to the login page. The authenticated
|
// 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 {
|
func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token, ok := m.sessionToken(r)
|
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) {
|
if isSessionActivity(r) && m.sessions.Touch(token) {
|
||||||
m.setSessionCookie(w, 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))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -43,3 +44,15 @@ func CurrentUser(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireGlobal wraps a handler that only global administrators may reach.
|
||||||
|
func RequireGlobal(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := CurrentPrincipal(r.Context())
|
||||||
|
if !ok || !p.IsGlobal() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *setupManager) bootstrap() error {
|
func (m *setupManager) bootstrap() error {
|
||||||
done, err := m.store.AdminExists()
|
done, err := m.store.UserExists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -48,9 +48,9 @@ func (m *setupManager) bootstrap() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *setupManager) activeToken() (string, bool) {
|
func (m *setupManager) activeToken() (string, bool) {
|
||||||
done, err := m.store.AdminExists()
|
done, err := m.store.UserExists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: setup: admin check failed: %v", err)
|
logf("panel: setup: user check failed: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ type Config struct {
|
|||||||
TLSCertFile string
|
TLSCertFile string
|
||||||
OpenDKIMSocket string
|
OpenDKIMSocket string
|
||||||
JournalSocket string
|
JournalSocket string
|
||||||
|
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||||
|
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||||
|
RateLimitMessagesPerIP int
|
||||||
|
RateLimitWindowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers holds dependencies for authenticated panel routes.
|
// Handlers holds dependencies for authenticated panel routes.
|
||||||
|
|||||||
@@ -14,21 +14,22 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleAccount serves the administrator's own account settings: the username
|
// HandleAccount serves the signed-in user's account settings.
|
||||||
// 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).
|
|
||||||
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
admin, err := h.store.GetAdmin()
|
p, ok := h.principal(r)
|
||||||
if err != nil {
|
if !ok {
|
||||||
logf("panel: account: get admin failed: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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:
|
case http.MethodPost:
|
||||||
h.submitAccount(w, r)
|
h.submitAccount(w, r)
|
||||||
default:
|
default:
|
||||||
@@ -37,33 +38,32 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderAccount draws the settings form. formUsername and formDMARCEmail
|
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||||
// 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) {
|
|
||||||
var reportAuth dnscheck.Result
|
var reportAuth dnscheck.Result
|
||||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
if showDMARC && formDMARCEmail != "" {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||||
reportAuth = h.dns.ReportAuth(ctx, hub)
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
cancel()
|
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.view.Render(w, status, "account", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — settings",
|
data["Title"] = "SelfPost — settings"
|
||||||
"User": auth.CurrentUser(r),
|
data["Active"] = "settings"
|
||||||
"Active": "account",
|
data["FormUsername"] = formUsername
|
||||||
"FormUsername": formUsername,
|
data["FormDMARCEmail"] = formDMARCEmail
|
||||||
"FormDMARCEmail": formDMARCEmail,
|
data["ShowDMARC"] = showDMARC
|
||||||
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
|
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
|
||||||
"ReportAuthExample": dnscheck.ReportAuthExample(),
|
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
|
||||||
"ReportAuthDNS": reportAuth,
|
data["ReportAuthDNS"] = reportAuth
|
||||||
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
|
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||||
"Error": formErr,
|
data["Error"] = formErr
|
||||||
"Flash": accountFlash(r),
|
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 {
|
func accountFlash(r *http.Request) string {
|
||||||
switch r.URL.Query().Get("updated") {
|
switch r.URL.Query().Get("updated") {
|
||||||
case "username":
|
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) {
|
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.auth.AllowLoginAttempt(r) {
|
if !h.auth.AllowLoginAttempt(r) {
|
||||||
|
p, _ := h.principal(r)
|
||||||
h.renderAccount(w, r, http.StatusTooManyRequests,
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,73 +115,76 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
password := r.PostFormValue("new_password")
|
password := r.PostFormValue("new_password")
|
||||||
confirm := r.PostFormValue("new_password_confirm")
|
confirm := r.PostFormValue("new_password_confirm")
|
||||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||||
|
if !p.IsGlobal() {
|
||||||
admin, err := h.store.GetAdmin()
|
dmarcEmail = user.DMARCReportEmail
|
||||||
if err != nil {
|
|
||||||
logf("panel: account: get admin failed: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = admin.Username
|
username = user.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||||
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
|
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renaming := username != admin.Username
|
renaming := username != user.Username
|
||||||
if renaming {
|
if renaming {
|
||||||
if err := validate.Username(username); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validate.Email(dmarcEmail); err != nil {
|
if p.IsGlobal() {
|
||||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
if err := validate.Email(dmarcEmail); err != nil {
|
||||||
return
|
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 != ""
|
repassword := password != "" || confirm != ""
|
||||||
if repassword {
|
if repassword {
|
||||||
if password != confirm {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := validate.AdminPassword(password); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !renaming && !repassword && !emailChanging {
|
if !renaming && !repassword && !emailChanging {
|
||||||
h.renderAccount(w, r, http.StatusBadRequest,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := admin.PasswordHash
|
hash := user.PasswordHash
|
||||||
if repassword {
|
if repassword {
|
||||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: account: hashing password failed: %v", err)
|
logf("panel: account: hashing password failed: %v", err)
|
||||||
h.renderAccount(w, r, http.StatusInternalServerError,
|
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||||
"Internal error. Please try again.", username, dmarcEmail)
|
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash = string(newHash)
|
hash = string(newHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
|
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
|
||||||
logf("panel: account: update admin failed: %v", err)
|
logf("panel: account: update user failed: %v", err)
|
||||||
msg := "Could not save the changes. Please check the logs and try again."
|
msg := "Could not save the changes. Please check the logs and try again."
|
||||||
if errors.Is(err, store.ErrNoAdmin) {
|
if errors.Is(err, store.ErrUserNotFound) {
|
||||||
msg = "There is no administrator account to update."
|
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
|
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)
|
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
|
||||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
|
||||||
"github.com/mixeme/selfpost/internal/web/validate"
|
"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
|
// 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
|
// this server signs with. Cached by the checker, so re-rendering the page
|
||||||
// after a form post costs nothing.
|
// after a form post costs nothing.
|
||||||
admin, err := h.store.GetAdmin()
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
profileEmail := admin.DMARCReportEmail
|
|
||||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
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"
|
dmarcSource = "settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
h.view.Render(w, status, "domain_detail", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — " + d.Name,
|
data["Title"] = "SelfPost — " + d.Name
|
||||||
"User": auth.CurrentUser(r),
|
data["Active"] = "domains"
|
||||||
"Active": "domains",
|
data["Domain"] = d
|
||||||
"Domain": d,
|
data["Record"] = record
|
||||||
"Record": record,
|
data["DNS"] = dns
|
||||||
"DNS": dns,
|
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
|
||||||
// SPF and DMARC are the operator's to write — SelfPost cannot generate
|
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
|
||||||
// them the way it generates the DKIM record — so the page shows what
|
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
|
||||||
// this server expects rather than leaving it to the documentation. The
|
data["DMARCSource"] = dmarcSource
|
||||||
// same builders phrase the suggestions in the check messages, so the
|
data["ProfileDMARCEmail"] = profileEmail
|
||||||
// page and the checks below it never recommend different records.
|
data["ResolvedDMARCEmail"] = reportEmail
|
||||||
"SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs),
|
data["DMARCRuaMode"] = dmarcMode
|
||||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
data["DMARCRuaCustom"] = dmarcCustom
|
||||||
"DMARCExample": dnscheck.DMARCExample(reportEmail),
|
data["ReportAuthName"] = reportAuthName
|
||||||
"DMARCSource": dmarcSource,
|
data["ReportAuthValue"] = reportAuthValue
|
||||||
"ProfileDMARCEmail": profileEmail,
|
data["NeedsReportAuth"] = needsReportAuth
|
||||||
"ResolvedDMARCEmail": reportEmail,
|
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||||
"DMARCRuaMode": dmarcMode,
|
data["Hostname"] = h.cfg.Hostname
|
||||||
"DMARCRuaCustom": dmarcCustom,
|
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||||
"ReportAuthName": reportAuthName,
|
data["Apps"] = appViews
|
||||||
"ReportAuthValue": reportAuthValue,
|
data["Error"] = view.FormErr
|
||||||
"NeedsReportAuth": needsReportAuth,
|
data["FormLogin"] = view.FormLogin
|
||||||
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
|
data["FormMode"] = view.FormMode
|
||||||
// Client connection settings (the same for every domain on this
|
data["FormAddrs"] = view.FormAddrs
|
||||||
// instance): the hostname clients connect to, and whether the optional
|
data["NewCred"] = view.NewCred
|
||||||
// submission listener is enabled in this deployment.
|
data["Flash"] = detailFlash(r)
|
||||||
"Hostname": h.cfg.Hostname,
|
data["Wildcard"] = store.AddressModeWildcard
|
||||||
"SubmissionEnabled": h.cfg.SubmissionEnabled,
|
data["List"] = store.AddressModeList
|
||||||
"Apps": appViews,
|
data["RateLimitErr"] = view.RateLimitErr
|
||||||
"Error": view.FormErr,
|
data["ExportErr"] = view.ExportErr
|
||||||
"FormLogin": view.FormLogin,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"FormMode": view.FormMode,
|
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||||
"FormAddrs": view.FormAddrs,
|
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||||
"NewCred": view.NewCred,
|
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||||
"Flash": detailFlash(r),
|
data["DomainRLMaxNum"] = domainRL.MaxMessages
|
||||||
"Wildcard": store.AddressModeWildcard,
|
data["L1Messages"] = h.l1Messages()
|
||||||
"List": store.AddressModeList,
|
data["L1Window"] = h.l1Window()
|
||||||
"RateLimitErr": view.RateLimitErr,
|
h.view.Render(w, status, "domain_detail", data)
|
||||||
"ExportErr": view.ExportErr,
|
|
||||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
|
||||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
|
||||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
|
||||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
|
||||||
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
// 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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
admin, err := h.store.GetAdmin()
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return store.Application{}, false
|
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
|
return a, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"github.com/mixeme/selfpost/internal/secretfile"
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
|
||||||
"github.com/mixeme/selfpost/internal/web/validate"
|
"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
|
// 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.
|
// its own card here rather than sharing a block on the domain list.
|
||||||
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
h.renderBackupPage(w, r, http.StatusOK, "")
|
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
|
// password), importErr to the import card, so neither message appears under the
|
||||||
// wrong form.
|
// wrong form.
|
||||||
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||||
h.view.Render(w, status, "backup", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — backup",
|
data["Title"] = "SelfPost — backup & migration"
|
||||||
"User": auth.CurrentUser(r),
|
data["Active"] = "backup"
|
||||||
"Active": "backup",
|
data["ImportErr"] = importErr
|
||||||
"ImportErr": importErr,
|
data["BackupErr"] = backupErr
|
||||||
"BackupErr": backupErr,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
h.view.Render(w, status, "backup", data)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleBackup streams a full-server backup as a download (architecture.md §
|
// 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
|
// way out, so the file that lands on their disk — wherever it is copied
|
||||||
// afterwards — is useless without the password.
|
// afterwards — is useless without the password.
|
||||||
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
password, pwErr := secretFilePassword(r)
|
password, pwErr := secretFilePassword(r)
|
||||||
if pwErr != "" {
|
if pwErr != "" {
|
||||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", 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
|
// re-renders the backup page, where the import form lives, with a friendly
|
||||||
// message.
|
// message.
|
||||||
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
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)
|
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
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).")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
if err != nil {
|
||||||
logf("panel: dashboard: list domains: %v", err)
|
logf("panel: dashboard: list domains: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.view.Render(w, status, "dashboard", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost",
|
data["Title"] = "SelfPost — domains"
|
||||||
"User": auth.CurrentUser(r),
|
data["Active"] = "domains"
|
||||||
"Active": "domains",
|
data["Domains"] = h.domainRows(domains)
|
||||||
"Domains": h.domainRows(domains),
|
data["Error"] = formErr
|
||||||
"Error": formErr,
|
data["FormName"] = formName
|
||||||
"FormName": formName,
|
data["Flash"] = dashboardFlash(r)
|
||||||
"Flash": dashboardFlash(r),
|
h.view.Render(w, status, "dashboard", data)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||||
profileEmail := ""
|
profileEmail := ""
|
||||||
if admin, err := h.store.GetAdmin(); err == nil {
|
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||||
profileEmail = admin.DMARCReportEmail
|
profileEmail = email
|
||||||
}
|
}
|
||||||
rows := make([]domainRow, len(domains))
|
rows := make([]domainRow, len(domains))
|
||||||
var wg sync.WaitGroup
|
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
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||||
// publish is shown (product.md).
|
// publish is shown (product.md).
|
||||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||||
return
|
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.
|
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
d, ok := h.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -118,11 +128,15 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "domains",
|
"Active": "domains",
|
||||||
"Domain": d,
|
"Domain": d,
|
||||||
|
"IsGlobal": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||||||
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
id, ok := parseDomainID(w, r)
|
id, ok := parseDomainID(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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
|
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||||
// sender map on demand (architecture.md § Panel HTTP surface).
|
// sender map on demand (architecture.md § Panel HTTP surface).
|
||||||
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err := h.domains.Resync(); err != nil {
|
||||||
logf("panel: manual reload (opendkim): %v", err)
|
logf("panel: manual reload (opendkim): %v", err)
|
||||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return store.Domain{}, false
|
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
|
return d, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,7 +39,9 @@ func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data["Title"] = "SelfPost — deliveries"
|
data["Title"] = "SelfPost — deliveries"
|
||||||
data["User"] = auth.CurrentUser(r)
|
for k, v := range h.pageBase(r) {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
data["Active"] = "deliveries"
|
data["Active"] = "deliveries"
|
||||||
h.view.Render(w, http.StatusOK, "deliveries", data)
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||||
logRows, logNote := h.deliveryLog(row)
|
logRows, logNote := h.deliveryLog(row)
|
||||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||||
"Title": "SelfPost — delivery",
|
"Title": "SelfPost — delivery",
|
||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "deliveries",
|
"Active": "deliveries",
|
||||||
|
"IsGlobal": p.IsGlobal(),
|
||||||
"Row": row,
|
"Row": row,
|
||||||
// The status in the panel's own badge vocabulary, so the headline reads
|
// The status in the panel's own badge vocabulary, so the headline reads
|
||||||
// the same way as every other health signal in the panel.
|
// the same way as every other health signal in the panel.
|
||||||
@@ -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
|
// deliveryEvent is one step of a message's history, as the timeline on the
|
||||||
// delivery page draws it. At is zero for the step that has not happened yet —
|
// delivery page draws it. At is zero for the step that has not happened yet —
|
||||||
// the delivery report a queued message is still waiting for.
|
// the delivery report a queued message is still waiting for.
|
||||||
@@ -269,11 +300,31 @@ func deliveriesBackURL(r *http.Request) string {
|
|||||||
// string, queries the store, and assembles everything the template needs
|
// string, queries the store, and assembles everything the template needs
|
||||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("no principal")
|
||||||
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.SendLogFilter{
|
filter := store.SendLogFilter{
|
||||||
Domain: q.Get("domain"),
|
Domain: q.Get("domain"),
|
||||||
AppLogin: q.Get("app"),
|
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"))
|
page := parsePage(q.Get("p"))
|
||||||
|
|
||||||
total, err := h.store.CountSendLog(filter)
|
total, err := h.store.CountSendLog(filter)
|
||||||
@@ -284,23 +335,35 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Decode on the way out as well as on the way in: rows the journal-milter
|
view := make([]sendLogRow, len(rows))
|
||||||
// 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.
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
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 {
|
domainNames := make([]string, 0, len(assigned))
|
||||||
return nil, err
|
for _, d := range assigned {
|
||||||
|
domainNames = append(domainNames, d.Name)
|
||||||
}
|
}
|
||||||
domainNames := make([]string, len(domains))
|
|
||||||
for i, d := range domains {
|
loginSet := make(map[string]bool)
|
||||||
domainNames[i] = d.Name
|
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()
|
logins := make([]string, 0, len(loginSet))
|
||||||
if err != nil {
|
for login := range loginSet {
|
||||||
return nil, err
|
logins = append(logins, login)
|
||||||
|
}
|
||||||
|
sort.Strings(logins)
|
||||||
|
|
||||||
|
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||||
|
filter.AppLogin = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPage := 1
|
lastPage := 1
|
||||||
@@ -308,7 +371,7 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|||||||
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Rows": rows,
|
"Rows": view,
|
||||||
"FilterDomains": domainNames,
|
"FilterDomains": domainNames,
|
||||||
"FilterApps": logins,
|
"FilterApps": logins,
|
||||||
"FilterDomain": filter.Domain,
|
"FilterDomain": filter.Domain,
|
||||||
@@ -335,18 +398,25 @@ func parsePage(v string) int {
|
|||||||
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
||||||
// surface).
|
// surface).
|
||||||
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
out, errText := readQueue()
|
out, errText := readQueue()
|
||||||
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
||||||
"Title": "SelfPost — mail queue",
|
"Title": "SelfPost — mail queue",
|
||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "mail_queue",
|
"Active": "mail_queue",
|
||||||
"Output": out,
|
"IsGlobal": true,
|
||||||
"Error": errText,
|
"Output": out,
|
||||||
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
||||||
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
out, errText := readQueue()
|
out, errText := readQueue()
|
||||||
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
||||||
"Output": out,
|
"Output": out,
|
||||||
@@ -369,18 +439,25 @@ func readQueue() (string, string) {
|
|||||||
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
||||||
// Panel HTTP surface).
|
// Panel HTTP surface).
|
||||||
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
lines, errText := h.readLogTail()
|
lines, errText := h.readLogTail()
|
||||||
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
||||||
"Title": "SelfPost — system log",
|
"Title": "SelfPost — system log",
|
||||||
"User": auth.CurrentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "system_log",
|
"Active": "system_log",
|
||||||
"Lines": lines,
|
"IsGlobal": true,
|
||||||
"Error": errText,
|
"Lines": lines,
|
||||||
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
||||||
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
lines, errText := h.readLogTail()
|
lines, errText := h.readLogTail()
|
||||||
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
||||||
"Lines": lines,
|
"Lines": lines,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"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
|
// 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()
|
t.Helper()
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
|
req = auth.RequestWithPrincipal(req, auth.Principal{
|
||||||
|
ID: 1,
|
||||||
|
Username: "admin",
|
||||||
|
Role: auth.RoleGlobal,
|
||||||
|
})
|
||||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||||
req.SetPathValue("id", rest)
|
req.SetPathValue("id", rest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,24 +19,73 @@ type rateLimitInput struct {
|
|||||||
windowSeconds int
|
windowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
func (h *Handlers) l1Messages() int {
|
||||||
|
if h.cfg.RateLimitMessagesPerIP > 0 {
|
||||||
|
return h.cfg.RateLimitMessagesPerIP
|
||||||
|
}
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) l1Window() int {
|
||||||
|
if h.cfg.RateLimitWindowSeconds > 0 {
|
||||||
|
return h.cfg.RateLimitWindowSeconds
|
||||||
|
}
|
||||||
|
return defaultRateLimitWindowSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
}
|
}
|
||||||
if r.PostFormValue("clear") != "" {
|
if r.PostFormValue("clear") != "" {
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{clear: true}, nil
|
||||||
}
|
}
|
||||||
|
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||||
|
if rawMax == "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||||
|
if err != nil || maxMessages <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||||
|
}
|
||||||
|
if maxMessages > l1Max {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||||
|
}
|
||||||
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
|
if err != nil || windowSeconds <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||||
|
}
|
||||||
|
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
|
}
|
||||||
|
if r.PostFormValue("clear") != "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||||
|
if rawMax == "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rateLimitInput{}, err
|
return rateLimitInput{}, err
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
|
||||||
}
|
}
|
||||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||||
if err != nil || maxMessages <= 0 {
|
if err != nil || maxMessages <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||||
}
|
}
|
||||||
|
if maxMessages > l1Max {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||||
|
}
|
||||||
|
if domainActive && maxMessages <= domainMax {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax)
|
||||||
|
}
|
||||||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
if err != nil || windowSeconds <= 0 {
|
if err != nil || windowSeconds <= 0 {
|
||||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||||
@@ -77,7 +126,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseDomainRateLimitForm(r, h.l1Messages())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
domainRL, domainOK, err := h.domains.RateLimit(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainActive := domainOK && domainRL.Active()
|
||||||
|
in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDomainRateLimitForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
form := func(vals url.Values) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"50"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100)
|
||||||
|
if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 {
|
||||||
|
t.Fatalf("valid domain = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||||
|
if err != nil || !in.clear {
|
||||||
|
t.Fatalf("empty max should clear: %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"150"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||||
|
t.Fatalf("over L1 want error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAppRateLimitForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
form := func(vals url.Values) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"80"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 {
|
||||||
|
t.Fatalf("valid app override = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"max_messages": {"80"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "trusted client IP") {
|
||||||
|
t.Fatalf("missing IPs want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"40"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 40, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "greater than the domain") {
|
||||||
|
t.Fatalf("app <= domain want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"150"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 0, false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||||
|
t.Fatalf("over L1 want error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No domain limit: any app ceiling ≤ L1 is fine.
|
||||||
|
in, err = parseAppRateLimitForm(form(url.Values{
|
||||||
|
"allowed_ips": {"203.0.113.10"},
|
||||||
|
"max_messages": {"50"},
|
||||||
|
"window_seconds": {"3600"},
|
||||||
|
}), 100, 0, false)
|
||||||
|
if err != nil || in.maxMessages != 50 {
|
||||||
|
t.Fatalf("app without domain = %+v err=%v", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,23 +9,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
data := h.statusBody()
|
data := h.statusBody()
|
||||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
|
||||||
|
|
||||||
data["Title"] = "SelfPost — status"
|
data["Title"] = "SelfPost — status"
|
||||||
data["User"] = auth.CurrentUser(r)
|
data["User"] = auth.CurrentUser(r)
|
||||||
data["Active"] = "status"
|
data["Active"] = "status"
|
||||||
|
data["IsGlobal"] = true
|
||||||
data["Flash"] = statusFlash(r)
|
data["Flash"] = statusFlash(r)
|
||||||
data["Hostname"] = h.cfg.Hostname
|
|
||||||
data["PTR"] = srv.PTR
|
|
||||||
h.view.Render(w, http.StatusOK, "status", data)
|
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())
|
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
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)
|
h.dns.Server(h.cfg.Hostname, true)
|
||||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -58,6 +64,7 @@ func (h *Handlers) statusBody() map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
machine := h.machine.Sample()
|
machine := h.machine.Sample()
|
||||||
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
|
|
||||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -71,6 +78,8 @@ func (h *Handlers) statusBody() map[string]any {
|
|||||||
"Cert": cert,
|
"Cert": cert,
|
||||||
"Sockets": sockets,
|
"Sockets": sockets,
|
||||||
"SocketStatus": socketStatus,
|
"SocketStatus": socketStatus,
|
||||||
|
"Hostname": h.cfg.Hostname,
|
||||||
|
"PTR": srv.PTR,
|
||||||
"OverallStatus": overall,
|
"OverallStatus": overall,
|
||||||
"OverallHeading": overallHeading(overall),
|
"OverallHeading": overallHeading(overall),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+347
-129
@@ -2,7 +2,36 @@
|
|||||||
layout so the panel's Content-Security-Policy can be a plain
|
layout so the panel's Content-Security-Policy can be a plain
|
||||||
"default-src 'self'" with no inline-style exemption. Any rule
|
"default-src 'self'" with no inline-style exemption. Any rule
|
||||||
added here must therefore stay here: an inline style="..." attribute in a
|
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
|
/* Colour tokens. Light values live on :root; the dark media query below
|
||||||
reassigns the same names rather than re-declaring every rule that uses
|
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. */
|
property already carries the right value for the active scheme. */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
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;
|
--card-bg: #fff;
|
||||||
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
|
--border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */
|
||||||
--control-border: #cfd4da; /* input, select, textarea, action buttons */
|
--control-border: #cbc8c1; /* input, select, textarea, action buttons */
|
||||||
--input-bg: #fff;
|
--input-bg: #fff;
|
||||||
--code-bg: #f0f2f4;
|
--code-bg: #efede9;
|
||||||
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
|
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||||
--nav-active-bg: #e6ebf5;
|
--nav-active-bg: #ede4de;
|
||||||
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
|
/* Brick, the mark's own colour, is the panel's accent — but as two values,
|
||||||
--credential-bg: #fffbeb; --credential-border: #f5c518;
|
not one. A filled control carries --accent-fill with white on it; a link or
|
||||||
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
|
an active entry carries --accent-text, because the fill on a page
|
||||||
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
|
background is a colour to sit behind text, not to be text. In the light
|
||||||
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
|
scheme the two coincide. */
|
||||||
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
|
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||||
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #14171a; --fg: #e6e8eb;
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
--card-bg: #1d2125;
|
--muted: #9aa1a9;
|
||||||
--border: #2b3138;
|
--card-bg: #1d2024;
|
||||||
--control-border: #2b3138;
|
--border: #2c2f34;
|
||||||
--input-bg: #14171a;
|
--control-border: #3a3e44;
|
||||||
--code-bg: #14171a;
|
--input-bg: #14161a;
|
||||||
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
|
--code-bg: #14161a;
|
||||||
--nav-active-bg: #22303f;
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
|
--nav-active-bg: #2a1f1b;
|
||||||
--credential-bg: #2a2408; --credential-border: #6b5a10;
|
/* Brick as drawn is about 2:1 against this background — a link nobody can
|
||||||
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
|
read. The fill lightens just enough to keep white on it above 4.5:1
|
||||||
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
|
(6.9:1 as set), and the text value lightens further to clear the same bar
|
||||||
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
|
as text on the page. This is where the two values earn their keep. */
|
||||||
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
@@ -54,7 +101,7 @@
|
|||||||
read as the layout jumping. */
|
read as the layout jumping. */
|
||||||
html { scrollbar-gutter: stable; }
|
html { scrollbar-gutter: stable; }
|
||||||
body {
|
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);
|
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
|
/* 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
|
/* 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
|
side margins above and pin the heading to the column's edge, a measure's
|
||||||
worth to the left of the card under it. */
|
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
|
/* 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
|
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
|
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
|
The subtitle stops resolving below 280px, which a 320px viewport still clears
|
||||||
once the body's padding is taken off. */
|
once the body's padding is taken off. */
|
||||||
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
.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 {
|
.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;
|
padding: 1.5rem; margin: 0 auto;
|
||||||
}
|
}
|
||||||
.card.narrow { max-width: 24rem; }
|
.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; }
|
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||||
input {
|
/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block
|
||||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
label and full-width input rules above: the box and its caption sit on one
|
||||||
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
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
|
/* One vocabulary for actions. Anything that performs an action looks like a
|
||||||
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
||||||
plain navigation (the delete confirmation page, the full queue view).
|
plain navigation (the delete confirmation page, the full queue view).
|
||||||
@@ -123,11 +208,25 @@ input {
|
|||||||
button, a.btn, a.danger {
|
button, a.btn, a.danger {
|
||||||
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
|
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
|
||||||
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||||
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
|
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover, a.btn:hover { background: #1d4ed8; }
|
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||||
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
|
button:disabled, button.danger:disabled {
|
||||||
.muted { color: #6b7280; }
|
opacity: 0.45; cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button:disabled:hover { background: var(--accent-fill); }
|
||||||
|
button.danger:disabled:hover { background: var(--danger-fill); }
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.65; cursor: not-allowed; background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
/* Links take the accent. The panel never set a colour here and ran on the
|
||||||
|
browser's own blue, which passed unnoticed while the accent was itself blue;
|
||||||
|
against brick it left every domain name and back link belonging to a palette
|
||||||
|
the panel no longer uses. The controls that are links dressed as buttons
|
||||||
|
(a.btn, a.danger, a.st) all carry a class and out-specify this. */
|
||||||
|
a { color: var(--accent-text); }
|
||||||
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
|
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
|
||||||
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
|
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
|
||||||
/* form.inline only exists so a POST can sit next to other content without a
|
/* form.inline only exists so a POST can sit next to other content without a
|
||||||
@@ -138,10 +237,14 @@ form.inline { display: inline; margin: 0; }
|
|||||||
card floated in the middle while the mark and the heading stayed at the far
|
card floated in the middle while the mark and the heading stayed at the far
|
||||||
left, three alignments on a page with four elements. Narrowing the column to
|
left, three alignments on a page with four elements. Narrowing the column to
|
||||||
the card's own width makes the three line up and puts the block as a whole in
|
the card's own width makes the three line up and puts the block as a whole in
|
||||||
the middle of the page. */
|
the middle of the page.
|
||||||
|
By page name and not by "the page holds a narrow card": signed-in pages that
|
||||||
|
hold a narrow card keep the 64rem column (see main:has(> .card.narrow)
|
||||||
|
above) so the navigation stays put; only these two pages have no nav and
|
||||||
|
need the column itself narrowed. */
|
||||||
main.page-login, main.page-setup { max-width: 24rem; }
|
main.page-login, main.page-setup { max-width: 24rem; }
|
||||||
.card + .card { margin-top: 1.2rem; }
|
.card + .card { margin-top: 1.2rem; }
|
||||||
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
|
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
/* A table column is at least as wide as the longest unbreakable run inside it,
|
/* A table column is at least as wide as the longest unbreakable run inside it,
|
||||||
and the panel's tables are full of runs with nothing to break on: email
|
and the panel's tables are full of runs with nothing to break on: email
|
||||||
@@ -149,11 +252,26 @@ table { width: 100%; border-collapse: collapse; }
|
|||||||
the send-log past its card and hang Status over the edge. Cells may break mid
|
the send-log past its card and hang Status over the edge. Cells may break mid
|
||||||
word, so a column can always be squeezed to the width available. */
|
word, so a column can always be squeezed to the width available. */
|
||||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||||
/* The exception: a timestamp broken across two lines is unreadable, and it is
|
/* The exceptions: a timestamp broken across two lines is unreadable, and so is
|
||||||
short enough to never be the reason a row does not fit. */
|
a send-log status — "deferred" was being hyphenless-broken into "deferre" and
|
||||||
td.time { white-space: nowrap; }
|
"d" whenever the row was tight. Both are short enough to never be the reason
|
||||||
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
|
a row does not fit. */
|
||||||
td.actions { text-align: right; }
|
td.time, td.status { white-space: nowrap; }
|
||||||
|
/* Column headings are set in the mono face: they are labels for machine data
|
||||||
|
rather than prose, and the wider tracking a small monospaced capital wants
|
||||||
|
also holds them apart from the first row of values. Same treatment on
|
||||||
|
.fact-label, so a heading of that size reads as one thing wherever it
|
||||||
|
appears. */
|
||||||
|
th {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||||
|
}
|
||||||
|
/* The controls at the end of a row are short and fixed — Details, Delete — and
|
||||||
|
the cell is the last one, so it is the one the automatic layout squeezes
|
||||||
|
first: in the send log it was breaking "Details" after "Detail". Holding it
|
||||||
|
on one line costs the row nothing and hands the width back to the status
|
||||||
|
column, which was wrapping single words for the same reason. */
|
||||||
|
td.actions { text-align: right; white-space: nowrap; }
|
||||||
/* Subject is the one cell whose text we do not control. Breaking mid word (the
|
/* Subject is the one cell whose text we do not control. Breaking mid word (the
|
||||||
rule above) keeps it inside the card, but a long subject would do it by
|
rule above) keeps it inside the card, but a long subject would do it by
|
||||||
growing the row several lines tall, which buries the rows around it. So the
|
growing the row several lines tall, which buries the rows around it. So the
|
||||||
@@ -164,9 +282,11 @@ td.subject span {
|
|||||||
display: block; max-width: 18rem;
|
display: block; max-width: 18rem;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
|
||||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
/* Semibold, not the browser's bold: a card's title sits a step under the page's
|
||||||
|
own heading, and 700 against a 300 h1 read as the louder of the two. */
|
||||||
|
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||||
/* The back link is a block so it takes the measure and lines up with the
|
/* The back link is a block so it takes the measure and lines up with the
|
||||||
heading above it and the card below: auto side margins centre a block, but
|
heading above it and the card below: auto side margins centre a block, but
|
||||||
they do nothing for an inline-block, which would have stayed at the column's
|
they do nothing for an inline-block, which would have stayed at the column's
|
||||||
@@ -176,7 +296,7 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
|||||||
.back { display: block; margin-bottom: 1rem; }
|
.back { display: block; margin-bottom: 1rem; }
|
||||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||||
reference material, not something to read on the way past. */
|
reference material, not something to read on the way past. */
|
||||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; line-height: 1.45; }
|
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); line-height: 1.45; }
|
||||||
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
||||||
.version a:hover { color: var(--fg); }
|
.version a:hover { color: var(--fg); }
|
||||||
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
|
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
|
||||||
@@ -184,12 +304,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
|||||||
main.page-login .version, main.page-setup .version { text-align: center; }
|
main.page-login .version, main.page-setup .version { text-align: center; }
|
||||||
select, textarea {
|
select, textarea {
|
||||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
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;
|
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
textarea { resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
button.danger, a.danger { background: #b42318; }
|
button.danger, a.danger { background: var(--danger-fill); }
|
||||||
button.danger:hover, a.danger:hover { background: #912018; }
|
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||||
compact rule below); it carries no marker because the pressed background
|
compact rule below); it carries no marker because the pressed background
|
||||||
already shows the open state. */
|
already shows the open state. */
|
||||||
@@ -204,28 +324,24 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
|
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
|
||||||
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||||
.app:last-child { padding-bottom: 0; }
|
.app:last-child { padding-bottom: 0; }
|
||||||
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||||
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
||||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||||
/* Mode and rate limit open under the whole row rather than inside it. As
|
/* Mode and rate limit open under the whole row rather than inside it. As
|
||||||
<details> the panel sat where its summary was, so opening one cut the row of
|
<details> the panel sat where its summary was, so opening one cut the row of
|
||||||
four controls in half and pushed the rest below a block of fields — the
|
controls in half and pushed the rest below a block of fields — the buttons
|
||||||
buttons moved every time a panel opened or closed. The panels are therefore
|
moved every time a panel opened or closed. The panel is therefore the last
|
||||||
the last children of the row, and the checkbox that opens each one is
|
child of the row, and the checkbox that opens it is visually hidden earlier
|
||||||
visually hidden earlier in the row with its label drawn as the button. The
|
in the row with its label drawn as the button. The checkbox stays in the tab
|
||||||
checkbox stays in the tab order and keeps its focus ring on the label, so it
|
order and keeps its focus ring on the label, so it works from the keyboard,
|
||||||
works from the keyboard, and being pure CSS it also works with JavaScript
|
and being pure CSS it also works with JavaScript blocked, as <details> did. */
|
||||||
blocked, as <details> did. */
|
|
||||||
.app .actions > .panel-toggle {
|
.app .actions > .panel-toggle {
|
||||||
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||||
}
|
}
|
||||||
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
||||||
.app .actions > .t-mode:checked ~ .panel-mode,
|
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||||
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
|
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||||
.app .actions > .t-mode:checked ~ .for-mode,
|
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
|
||||||
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
|
||||||
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
|
||||||
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
||||||
.panel form { margin-top: 0.6rem; }
|
.panel form { margin-top: 0.6rem; }
|
||||||
/* A panel's own submit is a form button, not one of the controls in the row
|
/* A panel's own submit is a form button, not one of the controls in the row
|
||||||
above, so it takes back the spacing the compact .actions rule zeroes out:
|
above, so it takes back the spacing the compact .actions rule zeroes out:
|
||||||
@@ -242,6 +358,21 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
}
|
}
|
||||||
.app .actions > .panel .panel-buttons button,
|
.app .actions > .panel .panel-buttons button,
|
||||||
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
||||||
|
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
|
||||||
|
.app .actions > .panel .check-col > form { margin-top: 0; }
|
||||||
|
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
|
||||||
|
.app .actions > .panel .check-col > .muted { margin: 0; }
|
||||||
|
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
|
||||||
|
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
|
||||||
|
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
min-height: 2.55rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
|
||||||
|
field-sizing: fixed;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||||
authenticated page without each content template having to include it. */
|
authenticated page without each content template having to include it. */
|
||||||
@@ -250,11 +381,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
738px the panel was wide, so as a bar it had to be split over two rows — and
|
738px the panel was wide, so as a bar it had to be split over two rows — and
|
||||||
even then it cost the top of every page. Standing it up removes that: the
|
even then it cost the top of every page. Standing it up removes that: the
|
||||||
entries share one left edge to scan down, the session sits at the foot where
|
entries share one left edge to scan down, the session sits at the foot where
|
||||||
it is out of the way, and there is room between them for the current page's
|
it is out of the way. Sticky, so the list stays in view while the page
|
||||||
own sections (.sections below), which is what makes the long pages navigable.
|
scrolls past it. The layout template lists the blocks in the order they are
|
||||||
Sticky, so both lists stay in view while the page scrolls past them. The
|
drawn, so reading and tab order follow the eye without a CSS `order`. */
|
||||||
layout template lists the blocks in the order they are drawn, so reading and
|
|
||||||
tab order follow the eye without a CSS `order`. */
|
|
||||||
.nav {
|
.nav {
|
||||||
position: sticky; top: 2rem; align-self: flex-start;
|
position: sticky; top: 2rem; align-self: flex-start;
|
||||||
flex: none; width: 13.5rem;
|
flex: none; width: 13.5rem;
|
||||||
@@ -272,6 +401,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
of them. It may be long and there is nothing to break it on, so it is allowed
|
of them. It may be long and there is nothing to break it on, so it is allowed
|
||||||
to break mid word rather than widen the column. */
|
to break mid word rather than widen the column. */
|
||||||
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||||
|
.nav .session-user {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
/* The mark is a link, but not one of the column's entries: it takes none of the
|
/* The mark is a link, but not one of the column's entries: it takes none of the
|
||||||
padding and rounding the entry rule below applies, so its own edge lines up
|
padding and rounding the entry rule below applies, so its own edge lines up
|
||||||
with the entries' icons rather than sitting half a step inside them. */
|
with the entries' icons rather than sitting half a step inside them. */
|
||||||
@@ -293,48 +425,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
underline was what marked them as links, but a column of them reads as a
|
underline was what marked them as links, but a column of them reads as a
|
||||||
list of links already, and eight underlines down the left edge is a lot of
|
list of links already, and eight underlines down the left edge is a lot of
|
||||||
line for no added meaning. The hover background carries the affordance. */
|
line for no added meaning. The hover background carries the affordance. */
|
||||||
|
/* The entries are page text, not accented links: a column of eight brick rows
|
||||||
|
would leave nothing for the current one to stand out from — that is what the
|
||||||
|
active entry's colour is for. */
|
||||||
.nav a, .nav [aria-current] {
|
.nav a, .nav [aria-current] {
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
padding: 0.35rem 0.6rem; border-radius: 6px; text-decoration: none;
|
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
.nav a:hover { background: var(--surface-bg); }
|
.nav a:hover { background: var(--surface-bg); }
|
||||||
/* The marker moved with the bar: a rule under the entry made sense in a row,
|
/* The marker moved with the bar: a rule under the entry made sense in a row,
|
||||||
where the entries sat side by side; standing up, the current entry is marked
|
where the entries sat side by side; standing up, the current entry is marked
|
||||||
down its leading edge, which is the edge every entry shares. */
|
down its leading edge, which is the edge every entry shares. */
|
||||||
.nav [aria-current] {
|
.nav [aria-current] {
|
||||||
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 #2563eb;
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
}
|
}
|
||||||
/* Sign out carries an icon too, so it needs the same row layout; its padding
|
/* Sign out carries an icon too, so it needs the same row layout; its colours
|
||||||
and colours come from the compact button rule further down. */
|
come from the compact button rule further down, and its type is put back to
|
||||||
|
the column's beside it (see the rule after that one). */
|
||||||
.nav button { display: flex; align-items: center; gap: 0.5rem; }
|
.nav button { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
||||||
a link's blue and Sign out's red all carry through without a rule apiece. */
|
a link's blue and Sign out's red all carry through without a rule apiece. */
|
||||||
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||||
/* The current page's own sections, listed under the page entries. The domain
|
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
|
||||||
page and the status page are eight and nine cards tall, and the only way to
|
|
||||||
the card you came for was to scroll past all the ones you did not. Only pages
|
|
||||||
that long carry an index — it comes from the page's own "sections" template
|
|
||||||
(see layout.html), so a page with two cards renders nothing here. */
|
|
||||||
.sections {
|
|
||||||
display: flex; flex-direction: column; gap: 0.1rem;
|
|
||||||
padding-top: 0.75rem; border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.sections-title {
|
|
||||||
margin: 0 0 0.25rem 0.6rem;
|
|
||||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
/* Quieter and a step in from the page entries: this is an index of one page,
|
|
||||||
subordinate to the list of pages above it. */
|
|
||||||
.nav .sections a {
|
|
||||||
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
|
||||||
font-size: 0.85rem; color: #6b7280; text-decoration: none;
|
|
||||||
}
|
|
||||||
.nav .sections a:hover { color: var(--fg); }
|
|
||||||
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
|
||||||
nothing is marked and the list is still a working index. */
|
|
||||||
.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); }
|
|
||||||
/* Jumping to a card should not leave it touching the top edge of the window. */
|
|
||||||
.card[id] { scroll-margin-top: 1rem; }
|
.card[id] { scroll-margin-top: 1rem; }
|
||||||
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
||||||
and the 48rem measure, plus the body's padding — the measure, not the column
|
and the 48rem measure, plus the body's padding — the measure, not the column
|
||||||
@@ -358,18 +472,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
.nav .brand img { width: 110px; }
|
.nav .brand img { width: 110px; }
|
||||||
/* Each block keeps its own group of entries together and wraps as one; the
|
/* Each block keeps its own group of entries together and wraps as one; the
|
||||||
rules that separated the blocks vertically become the space between them. */
|
rules that separated the blocks vertically become the space between them. */
|
||||||
.nav .links, .nav .session, .nav .sections {
|
.nav .links, .nav .session {
|
||||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||||
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
||||||
}
|
}
|
||||||
.sections-title { margin: 0; }
|
|
||||||
}
|
}
|
||||||
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
||||||
status page and the per-domain DNS checks, so a colour means the same thing
|
status page and the per-domain DNS checks, so a colour means the same thing
|
||||||
everywhere. The class suffix is the check's own status value. */
|
everywhere. The class suffix is the check's own status value. */
|
||||||
|
/* Mono and squared off rather than a bold uppercase pill: the badge carries a
|
||||||
|
value the server produced (ok, warn, delivered, bounced), and the mono face
|
||||||
|
is where the panel puts machine output everywhere else. Uppercasing it was
|
||||||
|
the pill's own emphasis — dropped with the pill, since the colour already
|
||||||
|
says how loud the badge is. */
|
||||||
|
/* line-height 1 and the padding doing the centring, rather than the 1.5 the
|
||||||
|
badge would inherit from the body. IBM Plex Mono sits a shade low in its em
|
||||||
|
square, so equal padding leaves "ok" / "warn" sunk in the box (and visibly
|
||||||
|
below the heading or label it sits next to). The bottom padding is therefore
|
||||||
|
heavier than the top — enough to lift the word to the optical centre. A
|
||||||
|
top-heavy pad was tried earlier and made the sink worse. Words with a
|
||||||
|
descender (queued) hang into that bottom pad rather than growing the badge. */
|
||||||
.st {
|
.st {
|
||||||
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
|
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
vertical-align: middle; border: 1px solid transparent;
|
vertical-align: middle; border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||||
@@ -395,8 +521,20 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
|||||||
beside them would otherwise win the width and leave "Memory" broken across
|
beside them would otherwise win the width and leave "Memory" broken across
|
||||||
two lines. */
|
two lines. */
|
||||||
.metric { white-space: nowrap; }
|
.metric { white-space: nowrap; }
|
||||||
|
/* Copy sits at the top of the value — right for a long DKIM key. The row's
|
||||||
|
.code uses the compact button's vertical padding so a one-line value is the
|
||||||
|
same height as Copy and does not leave the button hanging under an empty
|
||||||
|
band of padding. */
|
||||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
.code-row .code { flex: 1; min-width: 0; }
|
.code-row .code {
|
||||||
|
flex: 1; min-width: 0; margin-top: 0;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
/* A short field with its submit on the same row (Add domain). The button
|
||||||
|
keeps the card's usual filled look; its top margin is for stacked forms. */
|
||||||
|
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.input-row input { flex: 1; min-width: 0; }
|
||||||
|
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||||
|
|
||||||
/* Two cards abreast, on the one page that has a pair of them worth reading
|
/* Two cards abreast, on the one page that has a pair of them worth reading
|
||||||
together (a delivery's facts beside its history). auto-fit with a minimum
|
together (a delivery's facts beside its history). auto-fit with a minimum
|
||||||
@@ -407,12 +545,85 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
|||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
gap: 1.2rem; margin-top: 1.2rem;
|
gap: 1.2rem; margin-top: 1.2rem;
|
||||||
}
|
}
|
||||||
|
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
|
||||||
|
grid those auto side margins absorb free space and shrink each card to its
|
||||||
|
content instead of filling the 1fr track — so cancel them here, and allow
|
||||||
|
the cell to shrink below the card's intrinsic minimum when the column is
|
||||||
|
tight. */
|
||||||
|
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
|
||||||
/* The gap already spaces the cards inside the grid; the general .card + .card
|
/* The gap already spaces the cards inside the grid; the general .card + .card
|
||||||
rule would add a second gap's worth on top of it, and only to the second
|
rule would add a second gap's worth on top of it, and only to the second
|
||||||
card, which in one column reads as an uneven stack. The blocks around the
|
card, which in one column reads as an uneven stack. The blocks around the
|
||||||
grid keep their spacing from it instead. */
|
grid keep their spacing from it instead. */
|
||||||
.split > .card + .card { margin-top: 0; }
|
.split > .card + .card { margin-top: 0; }
|
||||||
.split + .card { margin-top: 1.2rem; }
|
.split + .card { margin-top: 1.2rem; }
|
||||||
|
.card + .split { margin-top: 1.2rem; }
|
||||||
|
/* Configuration sits outside the polled fragment; without this it would
|
||||||
|
touch the last .split inside #status-body (a nested .split is not a sibling). */
|
||||||
|
#status-body + .card { margin-top: 1.2rem; }
|
||||||
|
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
|
||||||
|
two; domain settings; application edit). Same auto-fit idea as .split, so a
|
||||||
|
wide card gets two columns — not three — and falls to one when narrow. */
|
||||||
|
.check-cols {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.check-col { min-width: 0; }
|
||||||
|
.check-col > label:first-child { margin-top: 0; }
|
||||||
|
.check-col-title {
|
||||||
|
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.card > h2 + .check-col-title { margin-top: 0.55rem; }
|
||||||
|
/* Domain settings pairs DMARC ‖ rate limit: share four row tracks so titles,
|
||||||
|
leads, fields and action buttons line up across columns. */
|
||||||
|
@supports (grid-template-rows: subgrid) {
|
||||||
|
.check-cols-rows {
|
||||||
|
grid-template-rows: auto auto 1fr auto;
|
||||||
|
}
|
||||||
|
.check-cols-rows > .check-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: subgrid;
|
||||||
|
grid-row: span 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
|
||||||
|
.check-cols-rows > .check-col > .muted { margin: 0; }
|
||||||
|
.check-col-fields { min-width: 0; }
|
||||||
|
.check-col-fields > form { margin: 0; }
|
||||||
|
.check-col-fields .field-pair { margin-top: 0; }
|
||||||
|
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
|
||||||
|
.check-col-actions {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.check-col-actions > button { margin-top: 1.2rem; }
|
||||||
|
.check-col-actions > form.inline { margin: 0; }
|
||||||
|
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
|
||||||
|
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
|
||||||
|
one column when the parent is too narrow for both. */
|
||||||
|
.field-pair {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||||
|
gap: 0 1rem; margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-pair > div { min-width: 0; }
|
||||||
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
|
/* Hint under a field-pair sits tight above the form's submit button (the
|
||||||
|
global button margin-top would leave a blank line). */
|
||||||
|
.field-pair + p.muted { margin: 0.45rem 0 0; }
|
||||||
|
.field-pair + p.muted + button { margin-top: 0.45rem; }
|
||||||
|
/* Host / name beside a DNS Type that is always TXT — size the type column to
|
||||||
|
that token rather than giving it half the row. Vertical padding matches
|
||||||
|
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
|
||||||
|
.field-pair.host-type {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.field-pair.host-type .code {
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-type { width: max-content; }
|
||||||
|
.field-type .code {
|
||||||
|
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||||
whose text we do not control: it may be a hundred characters with nothing to
|
whose text we do not control: it may be a hundred characters with nothing to
|
||||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||||
@@ -428,8 +639,8 @@ h1.subject { overflow-wrap: anywhere; }
|
|||||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||||
margin-top: -0.5rem; margin-bottom: 1rem;
|
margin-top: -0.5rem; margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.route .addr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; overflow-wrap: anywhere; }
|
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||||
.route .arrow { color: #6b7280; }
|
.route .arrow { color: var(--muted); }
|
||||||
/* A card's short facts, as a grid of tiles rather than the label-over-value
|
/* A card's short facts, as a grid of tiles rather than the label-over-value
|
||||||
stack the rest of the panel uses. That stack is right for the pages where a
|
stack the rest of the panel uses. That stack is right for the pages where a
|
||||||
value is a DNS record or a password to copy — one per line, full width — and
|
value is a DNS record or a password to copy — one per line, full width — and
|
||||||
@@ -437,16 +648,16 @@ h1.subject { overflow-wrap: anywhere; }
|
|||||||
would be a page of mostly empty rows. Same auto-fit as .split: as many
|
would be a page of mostly empty rows. Same auto-fit as .split: as many
|
||||||
columns as fit, and one when nothing else does. */
|
columns as fit, and one when nothing else does. */
|
||||||
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||||
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 8px; background: var(--surface-bg); }
|
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||||
.fact-label {
|
.fact-label {
|
||||||
display: block; font-size: 0.7rem; font-weight: 700;
|
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||||
text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280;
|
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||||
}
|
}
|
||||||
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
|
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
|
||||||
ids are all one run — so a value may break mid word rather than push its tile
|
ids are all one run — so a value may break mid word rather than push its tile
|
||||||
wider than the column it shares. */
|
wider than the column it shares. */
|
||||||
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
|
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
|
||||||
.fact-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
|
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
/* A message's history: the steps down a line, each with the time it happened,
|
/* A message's history: the steps down a line, each with the time it happened,
|
||||||
the status it reached and what that means. The line is the list's own left
|
the status it reached and what that means. The line is the list's own left
|
||||||
border and the dots sit on top of it, so nothing has to be positioned against
|
border and the dots sit on top of it, so nothing has to be positioned against
|
||||||
@@ -470,7 +681,7 @@ h1.subject { overflow-wrap: anywhere; }
|
|||||||
of anything. */
|
of anything. */
|
||||||
.event.pending { opacity: 0.7; }
|
.event.pending { opacity: 0.7; }
|
||||||
.event.pending::before { background: var(--card-bg); border-style: dashed; }
|
.event.pending::before { background: var(--card-bg); border-style: dashed; }
|
||||||
.event-time { margin: 0; font-size: 0.75rem; color: #6b7280; }
|
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||||
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||||
/* A message's own mail.log lines, as a table of when and what. The time column
|
/* A message's own mail.log lines, as a table of when and what. The time column
|
||||||
@@ -486,7 +697,7 @@ table.log th:first-child, table.log td.time { width: 1%; }
|
|||||||
while still letting a long line wrap inside the cell instead of widening it
|
while still letting a long line wrap inside the cell instead of widening it
|
||||||
(the th/td rule above allows the break). */
|
(the th/td rule above allows the break). */
|
||||||
table.log td.log-text {
|
table.log td.log-text {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem; white-space: pre-wrap;
|
font-size: 0.8rem; white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||||
@@ -497,31 +708,38 @@ table.log td.log-text {
|
|||||||
the control row of an application. */
|
the control row of an application. */
|
||||||
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
|
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
|
||||||
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||||
border-radius: 6px; white-space: nowrap;
|
border-radius: 5px; white-space: nowrap;
|
||||||
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
}
|
}
|
||||||
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
||||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||||
button.copy { flex: none; margin-top: 0.3rem; }
|
button.copy { flex: none; }
|
||||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||||
}
|
}
|
||||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
||||||
|
/* Sign out takes the compact button's colours but not its type. The rule above
|
||||||
|
is written for controls that cluster — a row of them beside a value or in a
|
||||||
|
table cell — where 0.8rem/600 is what keeps four of them from shouting. In
|
||||||
|
the navigation column it has no cluster to belong to: it stands under
|
||||||
|
Settings, among entries set 0.95rem/400, and being smaller and heavier than
|
||||||
|
every word around it made it read as a different kind of object rather than
|
||||||
|
as the last item of the list. Type and padding go back to the entries';
|
||||||
|
the red, the border and the background stay, and are what say it acts. */
|
||||||
|
.nav button {
|
||||||
|
padding: 0.35rem 0.6rem; font-size: 0.95rem; font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
/* The optional "encrypt this download" block on the backup and export forms.
|
/* The optional "encrypt this download" block on the backup and export forms.
|
||||||
Its label is the one checkbox in the panel, so it opts out of the
|
The checkbox uses the shared label.check rule above; the fields it reveals
|
||||||
block-level label rule above and sits on one line with its box; the fields it
|
are indented under it to read as its consequence rather than as three more
|
||||||
reveals are indented under it to read as its consequence rather than as three
|
fields of the form. panel.js hides the inner block until the box is ticked
|
||||||
more fields of the form. panel.js hides the inner block until the box is
|
(and empties it when unticked); without JavaScript everything stays visible,
|
||||||
ticked (and empties it when unticked); without JavaScript everything stays
|
which the server handles identically. The import form reuses the same
|
||||||
visible, which the server handles identically. The import form reuses the
|
indented .encrypt-fields look for its password field, but reveals it by
|
||||||
same indented .encrypt-fields look for its password field, but reveals it
|
file extension instead of a checkbox (see panel.js). */
|
||||||
by file extension instead of a checkbox (see panel.js). */
|
|
||||||
.encrypt { margin-top: 1.2rem; }
|
.encrypt { margin-top: 1.2rem; }
|
||||||
.encrypt label.check {
|
.encrypt label.check { margin: 0; }
|
||||||
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
|
|
||||||
}
|
|
||||||
.encrypt label.check input { width: auto; margin: 0; }
|
|
||||||
.encrypt-fields {
|
.encrypt-fields {
|
||||||
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||||
border-left: 2px solid var(--border);
|
border-left: 2px solid var(--border);
|
||||||
|
|||||||
@@ -72,6 +72,50 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Custom DMARC rua address shown only for "custom" mode ------------
|
||||||
|
// Same idea as the address list: the email field only applies when the
|
||||||
|
// operator picks Custom address. With JavaScript blocked the field stays
|
||||||
|
// visible and the server still ignores it for inherit/none.
|
||||||
|
function syncCustomAddressField(select) {
|
||||||
|
var form = select.closest("form");
|
||||||
|
var field = form && form.querySelector("[data-custom-address]");
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.hidden = select.value !== select.dataset.customMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCustomAddressFields(root) {
|
||||||
|
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
|
||||||
|
syncCustomAddressField(select);
|
||||||
|
select.addEventListener("change", function () {
|
||||||
|
syncCustomAddressField(select);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domain pick shown only for domain administrators ------------------
|
||||||
|
// Global administrators manage every domain, so the assignment checkboxes
|
||||||
|
// are irrelevant for that role. The toggle runs on load too, because the
|
||||||
|
// edit form of an existing global user should not flash the fieldset.
|
||||||
|
function syncDomainPickField(select) {
|
||||||
|
var form = select.closest("form");
|
||||||
|
var field = form && form.querySelector("[data-domain-pick]");
|
||||||
|
if (!field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.hidden = select.value === select.dataset.globalRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDomainPickFields(root) {
|
||||||
|
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
|
||||||
|
syncDomainPickField(select);
|
||||||
|
select.addEventListener("change", function () {
|
||||||
|
syncDomainPickField(select);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Encryption password fields shown only when asked for --------------
|
// --- Encryption password fields shown only when asked for --------------
|
||||||
// The backup, export and import forms carry an optional password block. It
|
// The backup, export and import forms carry an optional password block. It
|
||||||
// is hidden until the checkbox next to it is ticked, and cleared when it is
|
// is hidden until the checkbox next to it is ticked, and cleared when it is
|
||||||
@@ -135,73 +179,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Section index follows the page -----------------------------------
|
|
||||||
// The long pages list their own sections in the navigation column (the
|
|
||||||
// "sections" template). Marking the one currently in view turns that list
|
|
||||||
// from an index into a position, which is the whole point of it on a page
|
|
||||||
// nine cards tall. The links work without any of this; only the highlight
|
|
||||||
// depends on it.
|
|
||||||
//
|
|
||||||
// Each pass looks its targets up by id rather than holding on to elements
|
|
||||||
// found once: the status page replaces its cards wholesale every five
|
|
||||||
// seconds (adaptive polling on #status-body), and anything remembered here would be
|
|
||||||
// measuring boxes that had left the document.
|
|
||||||
var sectionLinks = [];
|
|
||||||
|
|
||||||
function markCurrentSection() {
|
|
||||||
var current = null;
|
|
||||||
sectionLinks.forEach(function (link) {
|
|
||||||
var target = document.getElementById(link.hash.slice(1));
|
|
||||||
// The section in view is the last one whose top has passed the reading
|
|
||||||
// line; the links are in document order, so the last match wins.
|
|
||||||
if (target && target.getBoundingClientRect().top <= 100) {
|
|
||||||
current = link;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
|
|
||||||
// At the foot of the page there is no scroll left to bring the last
|
|
||||||
// cards up to the reading line, so without this they could never be
|
|
||||||
// marked however far down you are — and the last card of the domain
|
|
||||||
// page is the one that deletes it.
|
|
||||||
current = sectionLinks[sectionLinks.length - 1];
|
|
||||||
} else if (!current) {
|
|
||||||
// Above the first heading nothing has been passed yet, and the page is
|
|
||||||
// still on its first section.
|
|
||||||
current = sectionLinks[0];
|
|
||||||
}
|
|
||||||
sectionLinks.forEach(function (link) {
|
|
||||||
link.classList.toggle("current", link === current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSectionIndex() {
|
|
||||||
sectionLinks = Array.prototype.slice.call(
|
|
||||||
document.querySelectorAll(".sections a[href^='#']")
|
|
||||||
);
|
|
||||||
if (!sectionLinks.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var pending = false;
|
|
||||||
// Scroll fires far more often than the highlight can change, so the work
|
|
||||||
// is collapsed onto the next frame.
|
|
||||||
window.addEventListener("scroll", function () {
|
|
||||||
if (pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pending = true;
|
|
||||||
window.requestAnimationFrame(function () {
|
|
||||||
pending = false;
|
|
||||||
markCurrentSection();
|
|
||||||
});
|
|
||||||
}, { passive: true });
|
|
||||||
markCurrentSection();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
initAddressFields(document);
|
initAddressFields(document);
|
||||||
|
initCustomAddressFields(document);
|
||||||
|
initDomainPickFields(document);
|
||||||
initEncryptFields(document);
|
initEncryptFields(document);
|
||||||
initImportPasswordField(document);
|
initImportPasswordField(document);
|
||||||
initSectionIndex();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Adaptive monitoring polling ---------------------------------------
|
// --- Adaptive monitoring polling ---------------------------------------
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
|
|||||||
// prompted this: a browser given no ETag and no Last-Modified caches it on a
|
// prompted this: a browser given no ETag and no Last-Modified caches it on a
|
||||||
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
||||||
func TestStaticAssetsCarryETag(t *testing.T) {
|
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||||
for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} {
|
for _, name := range []string{
|
||||||
|
"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js",
|
||||||
|
// The fonts are the assets this matters most for: they are the largest
|
||||||
|
// thing the panel serves and the ones a browser is most willing to keep.
|
||||||
|
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
|
||||||
|
} {
|
||||||
rec := serveStatic("/static/"+name, nil)
|
rec := serveStatic("/static/"+name, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .IsGlobal}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Add a sending domain</h2>
|
<h2>Add a sending domain</h2>
|
||||||
<form method="post" action="/domains">
|
<form method="post" action="/domains">
|
||||||
<label for="name">Domain</label>
|
<label for="name">Domain</label>
|
||||||
<input id="name" name="name" type="text" placeholder="example.com"
|
<div class="input-row">
|
||||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
<input id="name" name="name" type="text" placeholder="example.com"
|
||||||
value="{{.FormName}}" autofocus required>
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormName}}" autofocus required>
|
||||||
|
<button type="submit">Add domain</button>
|
||||||
|
</div>
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<button type="submit">Add domain</button>
|
|
||||||
</form>
|
</form>
|
||||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
|
||||||
shown DNS record. Adding a domain does not create an application.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Domains</h2>
|
<h2>Domains</h2>
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||||
<td class="muted">{{.DKIMSelector}}</td>
|
<td class="muted">{{.DKIMSelector}}</td>
|
||||||
<td>{{.AppCount}}</td>
|
<td>{{.AppCount}}</td>
|
||||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<td>{{.From}}</td>
|
<td>{{.From}}</td>
|
||||||
<td>{{.To}}</td>
|
<td>{{.To}}</td>
|
||||||
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
||||||
<td>{{.Status}}</td>
|
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
|
||||||
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
|
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
|
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a class="back" href="{{.BackURL}}">← Back to deliveries</a>
|
{{template "back_link" (back .BackURL "Back to deliveries")}}
|
||||||
|
|
||||||
{{/* The two columns: what was recorded on the left, in what order it happened
|
{{/* 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
|
on the right. They are a pair — the facts are only worth reading against
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Delete {{.Domain.Name}}</h1>
|
<h1>Delete {{.Domain.Name}}</h1>
|
||||||
|
|
||||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Confirm deletion</h2>
|
<h2>Confirm deletion</h2>
|
||||||
|
|||||||
@@ -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"}}
|
{{define "content"}}
|
||||||
<h1>{{.Domain.Name}}</h1>
|
<h1>{{.Domain.Name}}</h1>
|
||||||
|
|
||||||
<a class="back" href="/domains">← All domains</a>
|
{{template "back_link" (back "/domains" "All domains")}}
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||||
@@ -9,8 +13,8 @@
|
|||||||
{{if .NewCred}}
|
{{if .NewCred}}
|
||||||
<div class="card credential" id="new-credential">
|
<div class="card credential" id="new-credential">
|
||||||
<h2>New application password</h2>
|
<h2>New application password</h2>
|
||||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
|
||||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
— if it is lost, regenerate a new one.</p>
|
||||||
<label>Login</label>
|
<label>Login</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.NewCred.Login}}</span>
|
<span class="code">{{.NewCred.Login}}</span>
|
||||||
@@ -24,202 +28,279 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="card" id="dkim">
|
{{/* Two rows of two checks (.check-cols). */}}
|
||||||
<h2>DKIM DNS record</h2>
|
|
||||||
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
|
||||||
It is not a secret and can be viewed at any time.</p>
|
|
||||||
|
|
||||||
<label>Host / name</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.Record.Name}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>Type</label>
|
|
||||||
<span class="code">TXT</span>
|
|
||||||
|
|
||||||
<label>Value</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.Record.Value}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="spf-dmarc">
|
|
||||||
<h2>SPF and DMARC records</h2>
|
|
||||||
<p class="muted">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 <code>rua=</code> unless a
|
|
||||||
report address is configured below or in <a href="/account">Settings</a>.
|
|
||||||
Publish both as TXT records.</p>
|
|
||||||
|
|
||||||
<label>SPF — host / name</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.Domain.Name}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>SPF — value</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.SPFExample}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="muted">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 <code>all</code> term.</p>
|
|
||||||
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
|
||||||
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
|
|
||||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
|
|
||||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
|
||||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
|
||||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="dmarc_rua_email">Custom report address</label>
|
|
||||||
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
|
||||||
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
|
||||||
placeholder="reports@your-mail-domain.com">
|
|
||||||
|
|
||||||
<button type="submit">Save DMARC report settings</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<label>DMARC — host / name</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.DMARCName}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>DMARC — value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.DMARCExample}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .SameDomainRUA}}
|
|
||||||
<p class="error">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.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .NeedsReportAuth}}
|
|
||||||
<label>Report authorization — host / name</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.ReportAuthName}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>Report authorization — value</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.ReportAuthValue}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
|
|
||||||
<code>rua=</code> on a send-only domain, or point it at a mailbox that
|
|
||||||
receives inbound mail. Tighten to <code>p=quarantine</code> and then
|
|
||||||
<code>p=reject</code> once aggregate reports look clean.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="dns-status">
|
<div class="card" id="dns-status">
|
||||||
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
||||||
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
||||||
now, checked against the key this server signs with. Results are cached for a
|
publishing.</p>
|
||||||
few minutes — after publishing a record, use <em>Re-check</em>.</p>
|
|
||||||
|
|
||||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
<div class="check-cols">
|
||||||
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
<div class="check-col">
|
||||||
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||||
{{end}}</span>{{end}}
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.Record.Name}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DKIM.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .DNS.DKIM.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
<div class="check-col">
|
||||||
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||||
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
<div class="field-pair host-type">
|
||||||
{{end}}</span>{{end}}
|
<div>
|
||||||
<p class="muted">The SPF check is deliberately shallow: it looks for a
|
<label>Host / name</label>
|
||||||
mechanism that literally covers this server's address and does not follow
|
<span class="code">{{.Domain.Name}}</span>
|
||||||
<code>include:</code> or <code>redirect=</code>, so a record that authorises
|
</div>
|
||||||
the server through an include is reported as “cannot tell”, not as a failure.</p>
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.SPF.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .DNS.SPF.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
|
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
|
||||||
|
<code>redirect=</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
<div class="check-col">
|
||||||
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
<div class="field-pair host-type">
|
||||||
{{end}}</span>{{end}}
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.DMARCName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DMARC.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .DNS.DMARC.Status "ok"}}
|
||||||
|
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">{{.DNS.DMARC.Detail}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .DNS.DMARCReportAuth.Status}}
|
<div class="check-col">
|
||||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
{{if .DNS.DMARCReportAuth.Status}}
|
||||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||||
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
<div class="field-pair host-type">
|
||||||
{{end}}</span>{{end}}
|
<div>
|
||||||
{{end}}
|
<label>Host / name</label>
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .DNS.DMARCReportAuth.Records}}
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||||
|
{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||||
|
{{else}}
|
||||||
|
<label>Report authorization</label>
|
||||||
|
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||||
<button type="submit">Re-check</button>
|
<button type="submit">Re-check</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="settings">
|
<div class="split">
|
||||||
<h2>Sending server settings</h2>
|
<div class="card" id="dkim-spf">
|
||||||
<p class="muted">Point the mail client or script at these settings and
|
<h2>DKIM and SPF records</h2>
|
||||||
authenticate with an application login and password from the
|
|
||||||
<strong>Applications</strong> section below. They are the same for every
|
|
||||||
domain on this server.</p>
|
|
||||||
|
|
||||||
<label>Server</label>
|
<p class="check-col-title">DKIM</p>
|
||||||
<div class="code-row">
|
<div class="field-pair host-type">
|
||||||
<span class="code">{{.Hostname}}</span>
|
<div>
|
||||||
<button type="button" class="copy">Copy</button>
|
<label>Host / name</label>
|
||||||
</div>
|
<div class="code-row">
|
||||||
|
<span class="code">{{.Record.Name}}</span>
|
||||||
<label>Port and encryption</label>
|
<button type="button" class="copy">Copy</button>
|
||||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
</div>
|
||||||
587 — STARTTLS (submission){{end}}</span>
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
<p class="muted">Authentication is required on every port. The username is the
|
<label>Type</label>
|
||||||
application's login (see the table below) and the password is the one shown
|
<span class="code">TXT</span>
|
||||||
once when that application was created or its password regenerated — if it was
|
</div>
|
||||||
lost, generate a new one.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{/* Create form above the list, the same order the domains page uses for
|
|
||||||
"Add a sending domain" above "Domains". */}}
|
|
||||||
<div class="card" id="add-application">
|
|
||||||
<h2>Add an application</h2>
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
|
||||||
<label for="login">Login</label>
|
|
||||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
|
||||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
|
||||||
value="{{.FormLogin}}" required>
|
|
||||||
|
|
||||||
<label for="mode">Address mode</label>
|
|
||||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
|
||||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
|
||||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div data-addresses>
|
|
||||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
|
||||||
<textarea id="addresses" name="addresses" rows="3"
|
|
||||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
<label>Value</label>
|
||||||
<button type="submit">Create application</button>
|
<div class="code-row">
|
||||||
</form>
|
<span class="code">{{.Record.Value}}</span>
|
||||||
<p class="muted">A strong password is generated and shown once. The login must
|
<button type="button" class="copy">Copy</button>
|
||||||
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
</div>
|
||||||
|
|
||||||
|
<p class="muted">Not a secret. Signed with selector
|
||||||
|
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||||
|
|
||||||
|
<p class="check-col-title">SPF</p>
|
||||||
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.Domain.Name}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.SPFExample}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||||
|
do not publish a second record.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="dmarc">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
|
||||||
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.DMARCName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.DMARCExample}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .SameDomainRUA}}
|
||||||
|
<p class="error">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.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .NeedsReportAuth}}
|
||||||
|
<p class="check-col-title">Report authorization</p>
|
||||||
|
<div class="field-pair host-type">
|
||||||
|
<div>
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-type">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthValue}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
|
||||||
|
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
|
||||||
|
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<div class="card" id="connection">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
<p class="muted">Same for every domain. Authenticate with an application
|
||||||
|
login from below.</p>
|
||||||
|
|
||||||
|
<label>Server</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.Hostname}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Port and encryption</label>
|
||||||
|
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||||
|
587 — STARTTLS (submission){{end}}</span>
|
||||||
|
|
||||||
|
<p class="muted">Auth required on every port. The password is shown once at
|
||||||
|
create or regenerate.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Create form beside connection settings, mirroring "Add a sending domain"
|
||||||
|
above the domains list. */}}
|
||||||
|
<div class="card" id="add-application">
|
||||||
|
<h2>Add an application</h2>
|
||||||
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||||
|
<label for="login">Login</label>
|
||||||
|
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||||
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormLogin}}" required>
|
||||||
|
|
||||||
|
<label for="mode">Address mode</label>
|
||||||
|
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||||
|
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||||
|
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div data-addresses>
|
||||||
|
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea id="addresses" name="addresses" rows="3"
|
||||||
|
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<button type="submit">Create application</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Password shown once. Login unique across domains; letters,
|
||||||
|
digits, '.', '-' and '_'.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="applications">
|
<div class="card" id="applications">
|
||||||
<h2>Applications</h2>
|
<h2>Applications</h2>
|
||||||
<p class="muted">Each application is a SASL login/password an app or script
|
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
||||||
uses to send mail as this domain. A login may send from any address of the
|
address list.</p>
|
||||||
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
|
||||||
|
|
||||||
{{if .Apps}}
|
{{if .Apps}}
|
||||||
<ul class="apps">
|
<ul class="apps">
|
||||||
@@ -230,17 +311,12 @@
|
|||||||
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
||||||
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
||||||
</p>
|
</p>
|
||||||
<!-- The two panels are opened by a checkbox and a label rather than by
|
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
|
||||||
<details>, so that the controls stay a single row of buttons and
|
rather than <details>, so the button row stays intact — see
|
||||||
what a panel reveals is laid out under all four of them instead of
|
.panel-toggle in panel.css. -->
|
||||||
splitting the row in two. The checkbox precedes both its label and
|
|
||||||
its panel, which is what lets the CSS alone show them (no script
|
|
||||||
involved) — see .panel-toggle in panel.css. -->
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
|
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
|
||||||
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label>
|
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} <span class="st st-ok">limit</span>{{end}}</label>
|
||||||
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
|
|
||||||
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
|
|
||||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||||
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
||||||
<button type="submit">New password</button>
|
<button type="submit">New password</button>
|
||||||
@@ -249,44 +325,60 @@
|
|||||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||||
<button type="submit" class="danger">Delete</button>
|
<button type="submit" class="danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="panel panel-mode">
|
<div class="panel panel-edit">
|
||||||
<form method="post" action="/applications/{{.ID}}/mode">
|
<div class="check-cols">
|
||||||
<label>Address mode</label>
|
<div class="check-col">
|
||||||
<select name="mode" data-list-mode="{{$.List}}">
|
<p class="check-col-title">Address mode</p>
|
||||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
<p class="muted">Which From addresses this application may use.</p>
|
||||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
<form method="post" action="/applications/{{.ID}}/mode">
|
||||||
</select>
|
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
|
||||||
<div data-addresses>
|
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||||
<label>Addresses (one per line or comma-separated)</label>
|
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
</select>
|
||||||
|
<div data-addresses>
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||||
{{end}}{{$a}}{{end}}</textarea>
|
{{end}}{{$a}}{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save mode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Optional trusted-IP override
|
||||||
|
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||||
|
<p class="muted">One per line or comma-separated.</p>
|
||||||
|
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
||||||
|
<textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
|
||||||
|
aria-label="Trusted client IPs">{{.IPsText}}</textarea>
|
||||||
|
<p class="muted">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.</p>
|
||||||
|
<div class="field-pair">
|
||||||
|
<div>
|
||||||
|
<label>Message limit (max {{$.L1Messages}})</label>
|
||||||
|
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
|
||||||
|
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<!-- Saving and removing the limit are two posts; the Save
|
||||||
|
button is bound by form= id so both buttons share a row. -->
|
||||||
|
<div class="panel-buttons">
|
||||||
|
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
||||||
|
{{if .HasLimit}}
|
||||||
|
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
||||||
|
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
|
||||||
|
<input type="hidden" name="clear" value="1">
|
||||||
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Save mode</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="panel panel-limit">
|
|
||||||
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
|
||||||
<label>Expected client IPs (one per line or comma-separated)</label>
|
|
||||||
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
|
||||||
<label>Message limit</label>
|
|
||||||
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
|
||||||
<label>Window (seconds)</label>
|
|
||||||
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
|
||||||
</form>
|
|
||||||
<!-- Saving and removing the limit are two posts, so they are two
|
|
||||||
forms and could not share a line while the Save button sat
|
|
||||||
inside the block form above. It is bound to that form by id
|
|
||||||
instead (the form attribute, no script involved), which lets
|
|
||||||
both buttons stand together in one row under the fields. -->
|
|
||||||
<div class="panel-buttons">
|
|
||||||
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
|
||||||
{{if .HasLimit}}
|
|
||||||
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
|
||||||
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
|
|
||||||
<input type="hidden" name="clear" value="1">
|
|
||||||
<button type="submit" class="danger">Remove limit</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,84 +390,87 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="rate-limit">
|
<div class="card" id="domain-settings">
|
||||||
<h2>Sending rate limit (domain)</h2>
|
<h2>Domain settings</h2>
|
||||||
<p class="muted">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.</p>
|
|
||||||
|
|
||||||
<p class="muted">Status:
|
<div class="check-cols check-cols-rows">
|
||||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">DMARC reports</p>
|
||||||
|
<p class="muted">Default comes from <a href="/settings">Settings</a>;
|
||||||
|
override per domain here.</p>
|
||||||
|
<div class="check-col-fields">
|
||||||
|
<form id="dmarc-domain" method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||||
|
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||||
|
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||||
|
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||||
|
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||||
|
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
<div data-custom-address>
|
||||||
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
<label for="dmarc_rua_email">Custom report address</label>
|
||||||
<textarea id="d_ips" name="allowed_ips" rows="2"
|
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||||
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||||
|
placeholder="reports@your-mail-domain.com">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="check-col-actions">
|
||||||
|
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="d_max">Message limit</label>
|
<div class="check-col">
|
||||||
<input id="d_max" name="max_messages" type="number" min="1"
|
<p class="check-col-title">Level-2 rate limit
|
||||||
value="{{.DomainRLMax}}" placeholder="1000">
|
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||||
|
<p class="muted">Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
|
||||||
<label for="d_win">Window (seconds)</label>
|
<div class="check-col-fields">
|
||||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||||
|
<div class="field-pair">
|
||||||
<button type="submit">Save limit</button>
|
<div>
|
||||||
</form>
|
<label for="d_max">Message limit (max {{.L1Messages}})</label>
|
||||||
{{if .DomainHasRL}}
|
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
|
||||||
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
|
</div>
|
||||||
<input type="hidden" name="clear" value="1">
|
<div>
|
||||||
<button type="submit" class="danger">Remove limit</button>
|
<label for="d_win">Window (seconds)</label>
|
||||||
</form>
|
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||||
{{end}}
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Applies to every client IP on this domain. Leave the
|
||||||
|
message limit empty to use level 1 only.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="check-col-actions">
|
||||||
|
<button type="submit" form="rl-domain">Save limit</button>
|
||||||
|
{{if .DomainHasRL}}
|
||||||
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
||||||
|
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
|
||||||
|
<input type="hidden" name="clear" value="1">
|
||||||
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="export">
|
<div class="split">
|
||||||
<h2>Export domain</h2>
|
<div class="card" id="export">
|
||||||
<p class="muted">Download this domain to move it to another SelfPost instance:
|
<h2>Export domain</h2>
|
||||||
its DKIM key, selector and every application with its working password. On
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||||
import the DNS record stays the same, so no DNS change is needed.</p>
|
below as <code>.spde</code>.</p>
|
||||||
<p class="muted"><strong>The export file is a secret</strong> — it contains the
|
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||||
private DKIM key and application passwords. Transfer it securely and delete it
|
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||||
after the import, or encrypt it below and move a <code>.spde</code> file
|
{{template "encryptfields" .}}
|
||||||
(SelfPost domain export) instead; the import form asks for the password.</p>
|
<button type="submit">Export domain</button>
|
||||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
</form>
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
</div>
|
||||||
{{template "encryptfields" .}}
|
|
||||||
<button type="submit">Export domain</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="danger">
|
<div class="card" id="danger">
|
||||||
<h2>Danger zone</h2>
|
<h2>Danger zone</h2>
|
||||||
<p class="muted">Deleting this domain also deletes its DKIM key and every
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
application bound to it.</p>
|
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
</div>
|
||||||
</div>
|
|
||||||
{{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"}}
|
|
||||||
<div class="sections">
|
|
||||||
<p class="sections-title">On this page</p>
|
|
||||||
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
|
|
||||||
<a href="#dkim">DKIM DNS record</a>
|
|
||||||
<a href="#spf-dmarc">SPF and DMARC records</a>
|
|
||||||
<a href="#dns-status">DNS status</a>
|
|
||||||
<a href="#settings">Sending server settings</a>
|
|
||||||
<a href="#add-application">Add an application</a>
|
|
||||||
<a href="#applications">Applications</a>
|
|
||||||
<a href="#rate-limit">Sending rate limit</a>
|
|
||||||
<a href="#export">Export domain</a>
|
|
||||||
<a href="#danger">Danger zone</a>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -61,22 +61,26 @@
|
|||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||||
the column: the mark, the panel's pages, the current page's own sections,
|
the column: the mark, the panel's pages, and the session last. The mark
|
||||||
and the session last. The mark goes to /status, the page the panel opens
|
goes to /status, the page the panel opens on — named outright rather
|
||||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
than as "/", which is only a redirect to it. */}}
|
||||||
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||||
|
{{end}}
|
||||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||||
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||||
|
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">User: {{.User}}</span>
|
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -84,24 +88,22 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{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
|
{{/* 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
|
the reading measure unless it says otherwise; the pages that are tables of
|
||||||
tables of data or raw log lines redefine this as the word "wide", which
|
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||||
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
|
which lands in <main>'s class list (see main.wide in panel.css). Each
|
||||||
as "sections" above: each page's template file is parsed after this one,
|
page's template file is parsed after this one, so its definition replaces
|
||||||
so its definition replaces the empty one. */}}
|
the empty one. */}}
|
||||||
{{define "wide"}}{{end}}
|
{{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 <h1> and above the cards (see .back in
|
||||||
|
panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||||
|
{{define "back_link"}}
|
||||||
|
<a class="back" href="{{.Href}}">← {{.Label}}</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
{{/* 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
|
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
|
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. */}}
|
two drawers with handles is the picture of an archive. */}}
|
||||||
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||||
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{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"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{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"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
|
||||||
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
{{/* 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. */}}
|
open door with the arrow leading out of it. */}}
|
||||||
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||||
|
|||||||
+67
-8
@@ -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"}}
|
{{define "content"}}
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
{{if .ShowDMARC}}
|
||||||
|
<form method="post" action="/settings">
|
||||||
|
<div class="split">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only.
|
||||||
|
Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
|
||||||
<div class="card narrow">
|
|
||||||
<h2>Panel credentials</h2>
|
|
||||||
<p class="muted">These are the credentials for this control panel only.
|
|
||||||
Applications keep their own logins and passwords, which are not affected.</p>
|
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
||||||
<form method="post" action="/account">
|
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" name="username" autocomplete="username"
|
<input id="username" name="username" autocomplete="username"
|
||||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
@@ -22,8 +29,10 @@
|
|||||||
|
|
||||||
<label for="new_password_confirm">Confirm new password</label>
|
<label for="new_password_confirm">Confirm new password</label>
|
||||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>DMARC aggregate reports</h3>
|
<div class="card">
|
||||||
|
<h2>DMARC aggregate reports</h2>
|
||||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
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
|
receives inbound mail. SelfPost is send-only today; a future release will
|
||||||
@@ -58,11 +67,61 @@
|
|||||||
{{end}}</span>{{end}}
|
{{end}}</span>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Save changes</button>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username
|
||||||
|
or DMARC address only. Changing the password signs out every other session;
|
||||||
|
this one stays signed in.</p>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="card narrow">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only.
|
||||||
|
Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
<form method="post" action="/settings">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
|
|
||||||
|
<label for="current_password">Current password</label>
|
||||||
|
<input id="current_password" name="current_password" type="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<label for="new_password">New password</label>
|
||||||
|
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||||
|
|
||||||
|
<label for="new_password_confirm">Confirm new password</label>
|
||||||
|
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||||
|
|
||||||
<button type="submit">Save changes</button>
|
<button type="submit">Save changes</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted">Leave both new-password fields empty to change the username
|
<p class="muted">Leave both new-password fields empty to change the username
|
||||||
or DMARC address only. Changing the password signs out every other session;
|
only. Changing the password signs out every other session;
|
||||||
this one stays signed in.</p>
|
this one stays signed in.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card" id="rate-limits">
|
||||||
|
<h2>Sending rate limits</h2>
|
||||||
|
<p class="muted">Configured in <code>.env</code> / Compose; restart the
|
||||||
|
container to change level 1. Domain and application ceilings are set on
|
||||||
|
each domain's page.</p>
|
||||||
|
|
||||||
|
<label>Level 1 — per client IP (Postfix)</label>
|
||||||
|
<p class="code-row"><span class="code">{{.L1Messages}} messages / {{.L1Window}} seconds</span></p>
|
||||||
|
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> /
|
||||||
|
<code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP;
|
||||||
|
the panel cannot raise a domain or application limit above this.</p>
|
||||||
|
|
||||||
|
<label>Level 2 — domain</label>
|
||||||
|
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When
|
||||||
|
unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||||
|
|
||||||
|
<label>Level 2 — application (trusted IPs)</label>
|
||||||
|
<p class="muted">Optional override: list client IPs and a ceiling
|
||||||
|
<em>strictly above</em> the domain limit (still ≤ level 1). Those IPs
|
||||||
|
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -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"}}
|
{{define "content"}}
|
||||||
<h1>Server status</h1>
|
<h1>Status</h1>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
{{template "status_body" .}}
|
{{template "status_body" .}}
|
||||||
|
|
||||||
<div class="card" id="hostname">
|
|
||||||
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
|
||||||
<p class="muted">Receiving servers check that the name this server announces
|
|
||||||
resolves to its address <em>and</em> 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.</p>
|
|
||||||
|
|
||||||
<label>Server hostname</label>
|
|
||||||
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
|
|
||||||
|
|
||||||
{{if .PTR.Records}}
|
|
||||||
<label>Forward and reverse lookup</label>
|
|
||||||
<span class="code">{{range .PTR.Records}}{{.}}
|
|
||||||
{{end}}</span>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
|
|
||||||
|
|
||||||
<form class="inline" method="post" action="/status/recheck">
|
|
||||||
<button type="submit">Re-check DNS</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="configuration">
|
<div class="card" id="configuration">
|
||||||
<h2>Configuration</h2>
|
<h2>Configuration</h2>
|
||||||
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||||
@@ -41,22 +21,3 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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"}}
|
|
||||||
<div class="sections">
|
|
||||||
<p class="sections-title">On this page</p>
|
|
||||||
<a href="#overall">Overall</a>
|
|
||||||
<a href="#processes">Processes</a>
|
|
||||||
<a href="#machine">Machine</a>
|
|
||||||
<a href="#queue">Mail queue</a>
|
|
||||||
<a href="#certificate">TLS certificate</a>
|
|
||||||
<a href="#sockets">Milter sockets</a>
|
|
||||||
<a href="#hostname">Hostname and reverse DNS</a>
|
|
||||||
<a href="#configuration">Configuration</a>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -5,108 +5,125 @@
|
|||||||
<p class="muted">{{.OverallHeading}}</p>
|
<p class="muted">{{.OverallHeading}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="processes">
|
<div class="split">
|
||||||
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
<div class="card" id="machine">
|
||||||
{{if .ProcessError}}
|
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||||
<p class="error">Could not ask supervisord for the process list.</p>
|
<table>
|
||||||
{{else}}
|
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
<table>
|
<tbody>
|
||||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
<tr>
|
||||||
<tbody>
|
<td class="metric">CPU</td>
|
||||||
{{range .Processes}}
|
<td class="metric">
|
||||||
<tr>
|
{{if .Machine.CPU.Measured}}
|
||||||
<td>{{.Name}}</td>
|
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
||||||
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
{{.Machine.CPU.BusyText}}
|
||||||
<td class="muted">{{.Detail}}</td>
|
{{else}}<span class="muted">—</span>{{end}}
|
||||||
</tr>
|
</td>
|
||||||
{{end}}
|
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
{{end}}
|
<td class="metric">Memory</td>
|
||||||
|
<td class="metric">
|
||||||
|
{{if .Machine.Memory.Measured}}
|
||||||
|
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
||||||
|
{{.Machine.Memory.PctText}}
|
||||||
|
{{else}}<span class="muted">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Network</td>
|
||||||
|
<td class="metric">
|
||||||
|
{{if .Machine.Network.Measured}}
|
||||||
|
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
||||||
|
{{else}}<span class="muted">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="muted">
|
||||||
|
{{range .Machine.Network.Interfaces}}
|
||||||
|
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="processes">
|
||||||
|
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
||||||
|
{{if .ProcessError}}
|
||||||
|
<p class="error">Could not ask supervisord for the process list.</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Processes}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
||||||
|
<td class="muted">{{.Detail}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="machine">
|
<div class="split">
|
||||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
<div class="card" id="queue">
|
||||||
<p class="muted">Processor, memory and network of the machine this container
|
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
||||||
runs on, read from the kernel's counters. CPU and throughput are rates, so
|
{{if .QueueError}}
|
||||||
they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}}
|
<p class="error">{{.QueueError}}</p>
|
||||||
since the previous refresh rather than this instant.</p>
|
{{else}}
|
||||||
<table>
|
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
<a class="btn" href="/mail-queue">View queue</a>
|
||||||
<tbody>
|
{{end}}
|
||||||
<tr>
|
</div>
|
||||||
<td class="metric">CPU</td>
|
|
||||||
<td class="metric">
|
<div class="card" id="certificate">
|
||||||
{{if .Machine.CPU.Measured}}
|
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
||||||
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
{{if not .Cert.NotAfter.IsZero}}
|
||||||
{{.Machine.CPU.BusyText}}
|
<label>Expires</label>
|
||||||
{{else}}<span class="muted">—</span>{{end}}
|
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
|
||||||
</td>
|
{{end}}
|
||||||
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
|
||||||
<td class="metric">Memory</td>
|
|
||||||
<td class="metric">
|
|
||||||
{{if .Machine.Memory.Measured}}
|
|
||||||
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
|
||||||
{{.Machine.Memory.PctText}}
|
|
||||||
{{else}}<span class="muted">—</span>{{end}}
|
|
||||||
</td>
|
|
||||||
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="metric">Network</td>
|
|
||||||
<td class="metric">
|
|
||||||
{{if .Machine.Network.Measured}}
|
|
||||||
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
|
||||||
{{else}}<span class="muted">—</span>{{end}}
|
|
||||||
</td>
|
|
||||||
<td class="muted">
|
|
||||||
{{range .Machine.Network.Interfaces}}
|
|
||||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="queue">
|
<div class="split">
|
||||||
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
<div class="card" id="sockets">
|
||||||
{{if .QueueError}}
|
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||||
<p class="error">{{.QueueError}}</p>
|
<table>
|
||||||
{{else}}
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
<tbody>
|
||||||
<a class="btn" href="/mail-queue">Full queue</a>
|
{{range .Sockets}}
|
||||||
{{end}}
|
<tr>
|
||||||
</div>
|
<td>{{.Name}}</td>
|
||||||
|
<td><span class="st st-{{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td class="muted">{{.Detail}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" id="certificate">
|
<div class="card" id="hostname">
|
||||||
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
||||||
<p class="muted">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.</p>
|
|
||||||
{{if not .Cert.NotAfter.IsZero}}
|
|
||||||
<label>Expires</label>
|
|
||||||
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
|
|
||||||
{{end}}
|
|
||||||
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" id="sockets">
|
<label>Server hostname</label>
|
||||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
|
||||||
<table>
|
|
||||||
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
|
{{if .PTR.Records}}
|
||||||
<tbody>
|
<label>Forward and reverse lookup</label>
|
||||||
{{range .Sockets}}
|
<span class="code">{{range .PTR.Records}}{{.}}
|
||||||
<tr>
|
{{end}}</span>
|
||||||
<td>{{.Name}}</td>
|
{{end}}
|
||||||
<td class="muted">{{.Path}}</td>
|
|
||||||
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
|
<form class="inline" method="post" action="/status/recheck">
|
||||||
</tr>
|
<button type="submit">Re-check DNS</button>
|
||||||
{{end}}
|
</form>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
||||||
|
|
||||||
|
{{template "back_link" (back "/users" "Back to users")}}
|
||||||
|
|
||||||
|
<div class="card narrow">
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
|
|
||||||
|
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
|
||||||
|
|
||||||
|
<label for="role">Role</label>
|
||||||
|
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||||
|
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||||
|
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||||
|
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||||
|
</select>
|
||||||
|
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||||
|
|
||||||
|
<fieldset id="domain-pick" data-domain-pick>
|
||||||
|
<legend>Assigned domains</legend>
|
||||||
|
<p class="muted">Required for domain administrators.</p>
|
||||||
|
{{range .Domains}}
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
|
||||||
|
{{.Name}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
|
||||||
|
{{if .IsEdit}}
|
||||||
|
<button type="submit" name="action" value="delete" class="danger"{{if .LastGlobalLocked}} disabled{{end}}>Delete user</button>
|
||||||
|
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be deleted.</p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Users</h1>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p><a href="/users/new">Create user</a></p>
|
||||||
|
{{if .Users}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.User.Username}}</td>
|
||||||
|
<td>{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}</td>
|
||||||
|
<td class="muted">{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||||
|
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">No panel users besides the global administrator yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -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
|
// 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
|
// 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.
|
// 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
|
var buf bytes.Buffer
|
||||||
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
|
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||||
"User": "admin",
|
"User": "admin",
|
||||||
"Active": "mail_queue",
|
"Active": "mail_queue",
|
||||||
|
"IsGlobal": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("execute nav: %v", err)
|
t.Fatalf("execute nav: %v", err)
|
||||||
@@ -234,8 +162,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
|
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||||
"User": "admin",
|
"User": "admin",
|
||||||
"Active": "status",
|
"Active": "status",
|
||||||
|
"IsGlobal": true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("execute nav: %v", err)
|
t.Fatalf("execute nav: %v", err)
|
||||||
}
|
}
|
||||||
@@ -261,7 +190,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
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() {
|
for name, page := range engine.Pages() {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
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
|
// 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
|
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||||
// contain one.
|
// contain one.
|
||||||
@@ -351,17 +375,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
|||||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
`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 machine card: the bars carry their reading in an attribute
|
||||||
// (the CSP rules out sizing them with a style), and the figures are
|
// (the CSP rules out sizing them with a style), and the figures are
|
||||||
// printed beside them for anything that does not render a meter.
|
// printed beside them for anything that does not render a meter.
|
||||||
`<meter value="12"`, `<meter value="50"`,
|
`<meter value="12"`, `<meter value="50"`,
|
||||||
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB",
|
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
|
||||||
"eth0: 1.0 MiB in, 512.0 KiB out",
|
"eth0: 1.0 MiB in, 512.0 KiB out",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("status page is missing %q", want)
|
t.Errorf("status page is missing %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if got := strings.Count(out, `class="split"`); got != 3 {
|
||||||
|
t.Errorf("status page has %d .split rows, want 3", got)
|
||||||
|
}
|
||||||
|
// Hostname must live inside the fragment so a poll refresh keeps it beside
|
||||||
|
// sockets; Configuration stays outside (static reload control).
|
||||||
|
body := strings.Index(out, `id="status-body"`)
|
||||||
|
conf := strings.Index(out, `id="configuration"`)
|
||||||
|
if body < 0 || conf < 0 || conf < body {
|
||||||
|
t.Fatal("status-body or configuration card missing or out of order")
|
||||||
|
}
|
||||||
|
frag := out[body:conf]
|
||||||
|
if !strings.Contains(frag, `id="hostname"`) {
|
||||||
|
t.Error("hostname card is outside the polled status-body fragment")
|
||||||
|
}
|
||||||
|
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/reload"`) {
|
||||||
|
t.Error("configuration reload must stay outside the polled fragment")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A machine whose counters could not be read — no /proc, or a first reading
|
// A machine whose counters could not be read — no /proc, or a first reading
|
||||||
@@ -425,13 +470,13 @@ func statusPageData() map[string]any {
|
|||||||
},
|
},
|
||||||
"Machine": health.Machine{
|
"Machine": health.Machine{
|
||||||
CPU: health.CPU{
|
CPU: health.CPU{
|
||||||
Measured: true, BusyPct: 12.4, Cores: 4,
|
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
|
||||||
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
||||||
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19",
|
Status: health.StatusOK, Detail: "4 cores · 4 threads",
|
||||||
},
|
},
|
||||||
Memory: health.Memory{
|
Memory: health.Memory{
|
||||||
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
||||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.",
|
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
|
||||||
},
|
},
|
||||||
Network: health.Network{
|
Network: health.Network{
|
||||||
Measured: true, RxRate: 2048, TxRate: 1024,
|
Measured: true, RxRate: 2048, TxRate: 1024,
|
||||||
@@ -444,7 +489,7 @@ func statusPageData() map[string]any {
|
|||||||
Status: health.StatusOK,
|
Status: health.StatusOK,
|
||||||
},
|
},
|
||||||
"Sockets": []health.Socket{
|
"Sockets": []health.Socket{
|
||||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||||
},
|
},
|
||||||
"SocketStatus": health.StatusOK,
|
"SocketStatus": health.StatusOK,
|
||||||
"OverallStatus": health.StatusError,
|
"OverallStatus": health.StatusError,
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ var pageFiles = map[string][]string{
|
|||||||
"setup": {"templates/setup.html"},
|
"setup": {"templates/setup.html"},
|
||||||
"login": {"templates/login.html"},
|
"login": {"templates/login.html"},
|
||||||
"dashboard": {"templates/dashboard.html"},
|
"dashboard": {"templates/dashboard.html"},
|
||||||
"account": {"templates/account.html"},
|
"settings": {"templates/settings.html"},
|
||||||
|
"users": {"templates/users.html"},
|
||||||
|
"user_form": {"templates/user_form.html"},
|
||||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||||
"domain_delete": {"templates/domain_delete.html"},
|
"domain_delete": {"templates/domain_delete.html"},
|
||||||
@@ -63,7 +65,7 @@ func New(version string) (*Engine, error) {
|
|||||||
}
|
}
|
||||||
for name, files := range pageFiles {
|
for name, files := range pageFiles {
|
||||||
patterns := append([]string{"templates/layout.html"}, files...)
|
patterns := append([]string{"templates/layout.html"}, files...)
|
||||||
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
|
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,17 @@ func New(version string) (*Engine, error) {
|
|||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// templateFuncs supplies helpers shared across page templates.
|
||||||
|
func templateFuncs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
// back builds the map back_link reads; keeps href and label paired at
|
||||||
|
// the call site instead of repeating the <a class="back"> 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
|
// Page returns a parsed page template by logical name. It is exported for
|
||||||
// template guard tests that assert structural properties across all pages.
|
// template guard tests that assert structural properties across all pages.
|
||||||
func (e *Engine) Page(name string) *template.Template {
|
func (e *Engine) Page(name string) *template.Template {
|
||||||
|
|||||||
+40
-12
@@ -70,6 +70,11 @@ type Config struct {
|
|||||||
// checks must not go through the system resolver — see dnscheck's
|
// checks must not go through the system resolver — see dnscheck's
|
||||||
// externalResolver — so this is how a closed network points them at its own.
|
// externalResolver — so this is how a closed network points them at its own.
|
||||||
DNSResolvers []string
|
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.
|
// 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,
|
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||||
}, v, setupTokenPath)
|
}, v, setupTokenPath)
|
||||||
h := handlers.New(st, domains, apps, handlers.Config{
|
h := handlers.New(st, domains, apps, handlers.Config{
|
||||||
Hostname: cfg.Hostname,
|
Hostname: cfg.Hostname,
|
||||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||||
MailLogPath: cfg.MailLogPath,
|
MailLogPath: cfg.MailLogPath,
|
||||||
DataDir: cfg.DataDir,
|
DataDir: cfg.DataDir,
|
||||||
DBPath: cfg.DBPath,
|
DBPath: cfg.DBPath,
|
||||||
Version: cfg.Version,
|
Version: cfg.Version,
|
||||||
TLSCertFile: cfg.TLSCertFile,
|
TLSCertFile: cfg.TLSCertFile,
|
||||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||||
JournalSocket: cfg.JournalSocket,
|
JournalSocket: cfg.JournalSocket,
|
||||||
|
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||||
|
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
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)
|
mux.HandleFunc("/logout", s.auth.HandleLogout)
|
||||||
|
|
||||||
authed := http.NewServeMux()
|
authed := http.NewServeMux()
|
||||||
authed.HandleFunc("GET /{$}", redirectToStatus)
|
authed.HandleFunc("GET /{$}", redirectHome)
|
||||||
authed.HandleFunc("GET /status", h.HandleStatus)
|
authed.HandleFunc("GET /status", h.HandleStatus)
|
||||||
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
|
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
|
||||||
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
|
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 /applications/{aid}/delete", h.HandleDeleteApplication)
|
||||||
authed.HandleFunc("POST /reload", h.HandleReload)
|
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("GET /backup", h.HandleBackupPage)
|
||||||
authed.HandleFunc("POST /backup", h.HandleBackup)
|
authed.HandleFunc("POST /backup", h.HandleBackup)
|
||||||
@@ -168,7 +182,21 @@ func (s *Server) Handler() http.Handler {
|
|||||||
return s.secure(mux)
|
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)
|
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,12 @@ func TestE2E(t *testing.T) {
|
|||||||
if err := checkSupervisorProcesses(h); err != nil {
|
if err := checkSupervisorProcesses(h); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err := waitForPanelReady(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
|
|||||||
return appLogin, password, nil
|
return appLogin, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting)
|
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
|
||||||
// on either a domain (/domains/{id}/ratelimit) or an application
|
// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP
|
||||||
// (/applications/{id}/ratelimit).
|
// override; domain ceilings are posted without IPs.
|
||||||
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
|
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
|
||||||
resp, body, err := c.postForm(path, url.Values{
|
vals := url.Values{
|
||||||
"allowed_ips": {allowedIP},
|
|
||||||
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
||||||
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
|
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
|
||||||
})
|
}
|
||||||
|
if allowedIP != "" {
|
||||||
|
vals.Set("allowed_ips", allowedIP)
|
||||||
|
}
|
||||||
|
resp, body, err := c.postForm(path, vals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user