Compare commits
28 Commits
v1.0.0
..
669f928b4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 669f928b4d | |||
| f44f5333b3 | |||
| 652f1fe438 | |||
| c9d726dd64 | |||
| f59befdb00 | |||
| 15baa1e5d0 | |||
| c9076655b9 | |||
| e34d81498e | |||
| 155b721438 | |||
| efaf016c5f | |||
| 2bdc0ea9a8 | |||
| 6c8bf0d3b3 | |||
| c713f51f24 | |||
| 0ed36e9624 | |||
| 5178f80fdf | |||
| 10c41677bd | |||
| 01c754f857 | |||
| 691d3c255f | |||
| 295d1afb7d | |||
| 06c2014384 | |||
| a4cfa11323 | |||
| 236cb07769 | |||
| 04993e0da3 | |||
| 7eb168f418 | |||
| a1b6209470 | |||
| 44683a4996 | |||
| 870012514a | |||
| 012802d83d |
@@ -7,5 +7,4 @@ docs
|
|||||||
bin
|
bin
|
||||||
*.exe
|
*.exe
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
|
||||||
Makefile
|
Makefile
|
||||||
|
|||||||
@@ -5,10 +5,136 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: `/` redirects domain administrators to `/domains`; global users still
|
||||||
|
land on `/status`. Navigation hides global-only sections for domain
|
||||||
|
administrators.
|
||||||
|
- 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. A page
|
||||||
|
whose only card is the narrow one now takes its heading and footer down to the
|
||||||
|
card's width instead of splitting them across the wider measure, and the send
|
||||||
|
log stops breaking `Details` and `deferred` across two lines when a row is
|
||||||
|
tight. 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'`).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-10
|
||||||
|
|
||||||
|
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
|
||||||
|
packaging on every page, and an internal split of `internal/web` ahead of
|
||||||
|
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
|
||||||
|
existing DNS guidance is unchanged until you set a report address).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
|
||||||
|
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
|
||||||
|
is unchanged for `cmd/panel`. Templates and static assets moved under
|
||||||
|
`internal/web/view/`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
|
||||||
|
is now `p=none` without `rua=` by default; *Settings* and each domain page
|
||||||
|
let you configure an optional aggregate-report address (profile default plus
|
||||||
|
per-domain inherit / none / custom). When `rua=` targets another domain, the
|
||||||
|
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
|
||||||
|
Domain export/import carries per-domain overrides.
|
||||||
|
- AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
|
||||||
|
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); the
|
||||||
|
panel footer on every page — including login and setup — shows copyright, a
|
||||||
|
link to `/license` (embedded AGPL text), a Source link to the public
|
||||||
|
repository, and "No warranty"; the runtime image ships `LICENSE` and
|
||||||
|
`NOTICE` under `/usr/share/doc/selfpost/`. `docs/development.md` now lists
|
||||||
|
the vendored htmx asset beside the Go module licences.
|
||||||
|
- [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel
|
||||||
|
visual refresh: typography, colour tokens, spacing, and component styling
|
||||||
|
without behaviour changes). Starting reference:
|
||||||
|
[docs/assets/selfpost-proof.html](docs/assets/selfpost-proof.html). No semver
|
||||||
|
impact; explicit agreement required before coding, like other candidates.
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-08-09
|
||||||
|
|
||||||
|
A documentation and packaging release: no change to the mail path, the
|
||||||
|
database, or the on-disk layout. Upgrading is a tag bump.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
|
||||||
|
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
|
||||||
|
fixes, and what is in and out of scope for a relay. No response time is
|
||||||
|
promised. Without it a finder's default move is a public issue, which
|
||||||
|
discloses a relay flaw to everyone the moment it is filed.
|
||||||
|
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
|
||||||
|
inbound relay, the domain-admin role, and splitting the oversized `web`
|
||||||
|
package. Each states scope, open questions, and what has to be true before
|
||||||
|
coding starts. [docs/roadmap.md](docs/roadmap.md) is restructured around them
|
||||||
|
as a 1.x+ tracker instead of a 2.x wishlist, and now says how to read it from
|
||||||
|
outside the project: nothing in it is a commitment, there are no dates, and
|
||||||
|
the stated order is a recommendation.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||||
|
heading, browser title, and the operator guide. The route stays `/account`,
|
||||||
|
so existing links and bookmarks are unaffected.
|
||||||
|
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
|
||||||
|
the current account rather than as a stray word above the Settings link.
|
||||||
|
- [docs/product.md](docs/product.md) reframes the future line: the inbound
|
||||||
|
relay and the domain-admin role are agreed **1.x+** extensions tracked in the
|
||||||
|
roadmap and the plans, with the inbound relay targeting a MINOR bump by
|
||||||
|
default and a 2.x major still possible pending implementation. Only items the
|
||||||
|
roadmap still marks *candidate* need explicit approval before coding. It
|
||||||
|
previously put the whole line behind a 2.x.x that nothing had committed to.
|
||||||
|
- [docs/security.md](docs/security.md) is now in English, matching the rest of
|
||||||
|
the published docs — it is linked from the README table and from
|
||||||
|
`SECURITY.md`, so a reader following either landed in Russian. Content is
|
||||||
|
unchanged: same requirements, same accepted risks, same ADR. The reviewing
|
||||||
|
model is no longer named in the text; the fact that a pre-release review ran,
|
||||||
|
and its date, stay. The roadmap and the plans are in English for the same
|
||||||
|
reason, and neither records the model assigned to an item any more.
|
||||||
|
- The README documentation table now points at `SECURITY.md` for reporting a
|
||||||
|
vulnerability, and the `docs/security.md` row is renamed *Security design* —
|
||||||
|
with two files a reader could reasonably call "security", the table said
|
||||||
|
which is which only by accident. The roadmap row no longer calls the file
|
||||||
|
internal and Russian, because it is neither. `development.md` lists
|
||||||
|
`SECURITY.md` among the user-facing deliverables.
|
||||||
|
- `docs/development.md` records the decision on authorship: SelfPost is written
|
||||||
|
by AI agents under a maintainer's direction and the project discloses that,
|
||||||
|
so the `Co-Authored-By` trailers, the model routing table, and the agent
|
||||||
|
rules file all stay. Written down to settle the question rather than have it
|
||||||
|
reopened at each release.
|
||||||
|
- The two remaining Russian source comments are in English:
|
||||||
|
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
|
||||||
|
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
|
||||||
|
rather than translated — rendered in English it restated the sentence it was
|
||||||
|
attached to. The Cyrillic that remains is test data, where it is the point.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
- The panel's static assets are served with a content ETag and
|
||||||
`unknown blob` (and similar) errors after layers already uploaded.
|
`Cache-Control: no-cache`. They are embedded in the binary, so their
|
||||||
|
modification times are the zero value and no `Last-Modified` was sent; with
|
||||||
|
no validator at all the browser was free to guess how long to keep them,
|
||||||
|
which is why a tab kept showing the previous favicon after the new mark
|
||||||
|
shipped. Each asset is now hashed once at startup, so an unchanged one costs
|
||||||
|
a bodyless 304 and a changed one is picked up on the next load. A browser
|
||||||
|
that cached an asset *before* this release still has nothing to revalidate
|
||||||
|
against, so that one copy has to be cleared by hand.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-09
|
## [1.0.0] - 2026-08-09
|
||||||
|
|
||||||
@@ -59,6 +185,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
||||||
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
||||||
and mask the real error (release CI on both amd64 and arm64).
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
|
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||||
|
`unknown blob` (and similar) errors after layers already uploaded.
|
||||||
- A send-log row could stay `queued` forever after the container was recreated.
|
- A send-log row could stay `queued` forever after the container was recreated.
|
||||||
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
||||||
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||||
@@ -368,7 +496,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
||||||
unrecognised status word, a capitalised one, and a cleanup line.
|
unrecognised status word, a capitalised one, and a cleanup line.
|
||||||
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
||||||
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
|
*Backup and domain export* + accepted risk (encryption is opt-in);
|
||||||
`docs/architecture.md` persistence § envelope summary.
|
`docs/architecture.md` persistence § envelope summary.
|
||||||
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
||||||
release commit (move to `docs/archive/`, retarget its references in README,
|
release commit (move to `docs/archive/`, retarget its references in README,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
SelfPost
|
||||||
|
Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
|
||||||
|
This product is licensed under the GNU Affero General Public License,
|
||||||
|
version 3. The full licence text is in the LICENSE file in this
|
||||||
|
distribution (and at /license in the control panel).
|
||||||
|
|
||||||
|
Corresponding Source for the published releases is at:
|
||||||
|
https://github.com/mixeme/selfpost
|
||||||
|
|
||||||
|
If you modify SelfPost and let users interact with it over a network,
|
||||||
|
AGPL-3.0 §13 requires you to offer them the Corresponding Source of
|
||||||
|
your modified version. Update the Source link in the panel footer
|
||||||
|
(internal/web/view/templates/layout.html) so it points at your sources.
|
||||||
|
|
||||||
|
Third-party software included in this distribution
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
htmx 2.0.4 (internal/web/view/static/htmx.min.js)
|
||||||
|
Copyright (c) Big Sky Software
|
||||||
|
Licence: Zero-Clause BSD (0BSD)
|
||||||
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
|
IBM Plex Sans / IBM Plex Mono
|
||||||
|
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
|
||||||
|
Licence: SIL Open Font License 1.1
|
||||||
|
https://github.com/IBM/plex
|
||||||
|
Distributed unmodified as three latin-subset WOFF2 files served by the
|
||||||
|
control panel:
|
||||||
|
internal/web/view/static/ibm-plex-sans.woff2 (variable, 100-700)
|
||||||
|
internal/web/view/static/ibm-plex-mono-400.woff2
|
||||||
|
internal/web/view/static/ibm-plex-mono-600.woff2
|
||||||
|
Subsetting and WOFF2 packaging by Fontsource (fontsource.org), from
|
||||||
|
the Google Fonts distribution. The wordmarks in the shipped SVG logos
|
||||||
|
are outlined paths derived from the same fonts.
|
||||||
|
|
||||||
|
Go module dependencies are listed with their licences in
|
||||||
|
docs/development.md § External libraries. Debian packages in the
|
||||||
|
runtime image carry their own copyright files as shipped by Debian.
|
||||||
@@ -35,11 +35,14 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
||||||
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
|
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||||
|
the private reporting channel and the scope.
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
the `ghcr.io/mixeme/selfpost` image.
|
the `ghcr.io/mixeme/selfpost` image.
|
||||||
|
|
||||||
@@ -92,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.0.0
|
ghcr.io/mixeme/selfpost:1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -259,6 +262,9 @@ Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
Copyright © 2026 Mikhail Yenuchenko.
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
||||||
version as a network-accessible service, you must make the modified source
|
version as a network-accessible service, you must make the modified source
|
||||||
available to its users — not only when you distribute copies of the code.
|
available to its users — not only when you distribute copies of the code.
|
||||||
|
Third-party notices: [NOTICE](NOTICE).
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||||
|
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||||
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest 1.x | yes |
|
||||||
|
| earlier 1.x | no — upgrade first |
|
||||||
|
| 0.x | no (pre-release) |
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||||
|
the *Report a vulnerability* button under the repository's
|
||||||
|
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||||
|
it, mail `public@mixeme.ru` instead.
|
||||||
|
|
||||||
|
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||||
|
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||||
|
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||||
|
|
||||||
|
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||||
|
deadline that cannot be honoured is worse than none. Reports are read and
|
||||||
|
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||||
|
with the timeline agreed in the thread.
|
||||||
|
|
||||||
|
Disclosure is coordinated by request, not by demand: please hold public details
|
||||||
|
until a patch is out. If you get no reply, that is not a request for a
|
||||||
|
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||||
|
the CHANGELOG unless they ask not to be.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
The relay's job is to accept authenticated mail from an application and hand it
|
||||||
|
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||||
|
scope:
|
||||||
|
|
||||||
|
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||||
|
a domain the sending application is not bound to
|
||||||
|
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||||
|
from anything the container exposes
|
||||||
|
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||||
|
it was not granted
|
||||||
|
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||||
|
tokens, or backup encryption material leaking to an unauthorised party
|
||||||
|
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||||
|
CSRF on state-changing routes, privilege escalation
|
||||||
|
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||||
|
per-domain and per-application limits
|
||||||
|
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||||
|
user to root
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
These are the operator's responsibility or accepted trade-offs, documented in
|
||||||
|
[docs/security.md](docs/security.md) and the
|
||||||
|
[operator guide](docs/guide.md):
|
||||||
|
|
||||||
|
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||||
|
wrong PTR record, DNS records not published, a self-signed or expired
|
||||||
|
certificate on the reverse proxy
|
||||||
|
- Anything requiring the attacker to already have root on the host or write
|
||||||
|
access to the `./data` bind mount
|
||||||
|
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||||
|
never terminates HTTPS itself
|
||||||
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
|
a policy decision of that provider, not a defect
|
||||||
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
## Reports we cannot act on
|
||||||
|
|
||||||
|
Automated scanner output with no demonstrated impact, and reports whose only
|
||||||
|
content is a version number compared against a CVE list, are closed without
|
||||||
|
investigation.
|
||||||
@@ -86,6 +86,10 @@ RUN mkdir -p /run/opendkim /run/selfpost /data \
|
|||||||
COPY --from=build /out/panel /usr/local/bin/panel
|
COPY --from=build /out/panel /usr/local/bin/panel
|
||||||
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||||
|
|
||||||
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
|
# serves the same text at /license from an embedded copy.
|
||||||
|
COPY LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
|
|
||||||
COPY build/opendkim.conf /etc/opendkim.conf
|
COPY build/opendkim.conf /etc/opendkim.conf
|
||||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// several roles (architecture.md § Image and processes) as a supervised
|
// several roles (architecture.md § Image and processes) as a supervised
|
||||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||||
// the rate-limit checks.
|
// the rate-limit checks.
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// the same password:
|
// the same password:
|
||||||
//
|
//
|
||||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.0.0
|
image: ghcr.io/mixeme/selfpost:1.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
|
||||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
# step is required"). Run this on the host, after Traefik has issued or
|
||||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||||
# is not itself watched by SelfPost/Postfix.
|
# acme.json is not itself watched by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -188,10 +188,10 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
|||||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||||
absolute cap (regular use keeps the session alive indefinitely).
|
absolute cap (regular use keeps the session alive indefinitely).
|
||||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||||
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
- **Password change** — all other sessions are deleted; the current session stays
|
- **Password change** — all other sessions are deleted; the current session stays
|
||||||
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
[handlers_account.go](../internal/web/handlers_account.go)).
|
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
||||||
|
|
||||||
Restoring an **older** backup also restores session rows: a session invalidated
|
Restoring an **older** backup also restores session rows: a session invalidated
|
||||||
after that backup was taken can become valid again if the browser still has the
|
after that backup was taken can become valid again if the browser still has the
|
||||||
@@ -218,7 +218,15 @@ flowchart TB
|
|||||||
backupcli["selfpost-backup CLI"]
|
backupcli["selfpost-backup CLI"]
|
||||||
end
|
end
|
||||||
subgraph web ["internal/web — HTTP surface"]
|
subgraph web ["internal/web — HTTP surface"]
|
||||||
handlers["handlers_*.go, templates, session/security"]
|
webRoot["web.go — router, security"]
|
||||||
|
viewPkg["web/view — templates, static"]
|
||||||
|
authPkg["web/auth — session, login, setup"]
|
||||||
|
handlersPkg["web/handlers — authenticated pages"]
|
||||||
|
webRoot --> viewPkg
|
||||||
|
webRoot --> authPkg
|
||||||
|
webRoot --> handlersPkg
|
||||||
|
handlersPkg --> authPkg
|
||||||
|
handlersPkg --> viewPkg
|
||||||
end
|
end
|
||||||
subgraph services ["Services — multi-store operations + rollback"]
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
domainSvc["internal/domain"]
|
domainSvc["internal/domain"]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,16 +12,46 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||||
|
as-built — [architecture.md](architecture.md).
|
||||||
3. Skim [product.md](product.md) if scope is in doubt.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||||
|
roadmap index).
|
||||||
|
|
||||||
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
not duplicated here.
|
not duplicated here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authorship and disclosure
|
||||||
|
|
||||||
|
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
|
||||||
|
a maintainer's direction, and the project says so rather than hiding it.
|
||||||
|
|
||||||
|
Concretely, this is what "says so" means, and none of it is an oversight to be
|
||||||
|
tidied away later:
|
||||||
|
|
||||||
|
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
|
||||||
|
the ~140 commits that predate v1.0.
|
||||||
|
- The model routing table below is public, in a file the README links to.
|
||||||
|
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
|
||||||
|
the repository.
|
||||||
|
- Process notes written for an agent — "after a context reset, pick an item
|
||||||
|
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
|
||||||
|
|
||||||
|
**Why not quietly drop it.** Once the trailers are in the history, removing the
|
||||||
|
routing table or the rules file would not conceal authorship, it would only
|
||||||
|
make the project look like it was trying to. Partial concealment reads worse
|
||||||
|
than the plain statement, and the plain statement costs nothing: the code is
|
||||||
|
reviewed, tested, and shipped under the same rules either way, and the
|
||||||
|
[security design](security.md) records what was audited and what was accepted.
|
||||||
|
|
||||||
|
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
|
||||||
|
obligation — not because the convention around AI authorship shifts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Model routing
|
## Model routing
|
||||||
|
|
||||||
| Kind of work | Model | Examples |
|
| Kind of work | Model | Examples |
|
||||||
@@ -61,8 +92,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
|
|||||||
|
|
||||||
## External libraries
|
## External libraries
|
||||||
|
|
||||||
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must
|
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||||||
be permissive or GPL-family (see
|
third-party notices: [NOTICE](../NOTICE). New Go dependencies must be
|
||||||
|
permissive or GPL-family (see
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
|
|
||||||
### Main module (`go.mod`)
|
### Main module (`go.mod`)
|
||||||
@@ -76,6 +108,12 @@ be permissive or GPL-family (see
|
|||||||
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
||||||
the tree are AGPL-3.0-compatible.
|
the tree are AGPL-3.0-compatible.
|
||||||
|
|
||||||
|
### Vendored front-end
|
||||||
|
|
||||||
|
| Asset | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
|
||||||
|
|
||||||
### E2e module (`test/e2e/go.mod`)
|
### E2e module (`test/e2e/go.mod`)
|
||||||
|
|
||||||
| Package | Version | Repository | License |
|
| Package | Version | Repository | License |
|
||||||
@@ -90,6 +128,8 @@ the image.
|
|||||||
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||||
from Debian bookworm repositories; licenses are in each package's `copyright`
|
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||||
file on <https://packages.debian.org/bookworm/>.
|
file on <https://packages.debian.org/bookworm/>.
|
||||||
|
The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under
|
||||||
|
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,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.0.0
|
make build VERSION=1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
@@ -273,7 +313,8 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
@@ -282,7 +323,9 @@ There is no `docs/archive/` directory.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||||||
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||||
|
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
|
||||||
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||||
|
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
|
||||||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||||||
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||||
@@ -311,7 +354,7 @@ to prose.
|
|||||||
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||||
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||||
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||||||
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
|
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
|
||||||
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||||||
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||||||
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||||
|
|||||||
@@ -148,8 +148,13 @@ Two different scopes — don't confuse them:
|
|||||||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||||
- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts
|
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||||
building reporting/reputation history).
|
only, safe to publish immediately). On a send-only relay the sending domain
|
||||||
|
often has no inbox, so `rua=` is optional — configure a default report address
|
||||||
|
in *Settings* or per domain when you have a mailbox that receives inbound mail
|
||||||
|
elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that
|
||||||
|
hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …)
|
||||||
|
cannot be used as external report destinations.
|
||||||
|
|
||||||
Skipping any of the three per-domain records is the single most common reason
|
Skipping any of the three per-domain records is the single most common reason
|
||||||
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||||
@@ -160,7 +165,8 @@ The panel checks both scopes for you and tells you what is actually published:
|
|||||||
the *Status* page verifies the server's hostname and its reverse record
|
the *Status* page verifies the server's hostname and its reverse record
|
||||||
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
||||||
card comparing the published DKIM record against the key this server signs with,
|
card comparing the published DKIM record against the key this server signs with,
|
||||||
plus the domain's SPF and DMARC records. Results are cached for a few minutes;
|
plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation
|
||||||
|
records. Results are cached for a few minutes;
|
||||||
use *Re-check* right after publishing a record. The SPF check is deliberately
|
use *Re-check* right after publishing a record. The SPF check is deliberately
|
||||||
shallow — it looks for a mechanism that literally covers this server's address
|
shallow — it looks for a mechanism that literally covers this server's address
|
||||||
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
||||||
@@ -223,7 +229,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).
|
||||||
- **Account** (`/account`) — change the administrator username and/or password.
|
- **Settings** (`/account`) — 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
|
||||||
@@ -375,7 +381,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.0.0`. Intermediate
|
deliberately never `:latest`. The current pin is `1.1.0`. Intermediate
|
||||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
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
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Plan: dmarc-reports
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
SelfPost **receives** DMARC aggregate reports on SMTP, parses them inside the
|
||||||
|
image, and **shows summaries in the panel** — pass/fail by source, hints when
|
||||||
|
`tighten p=` is reasonable. No external DMARC SaaS and no IMAP workflow for the
|
||||||
|
operator.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- Inbound SMTP for configured report addresses only (not a general backup-MX).
|
||||||
|
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
|
||||||
|
- Panel page and/or per-domain section: recent reports, third-party senders,
|
||||||
|
delivery health of report ingestion.
|
||||||
|
- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates;
|
||||||
|
when enabled, suggest a SelfPost-hosted report address.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
- Forensic reports (`ruf=`).
|
||||||
|
- Full dashboards, APIs, email alerting.
|
||||||
|
- Mailboxes for people (IMAP/POP3/webmail).
|
||||||
|
|
||||||
|
## Architecture (sketch)
|
||||||
|
|
||||||
|
1. Receiving MTAs → SMTP to SelfPost (hub MX).
|
||||||
|
2. Postfix virtual alias or dedicated listener → panel ingest worker.
|
||||||
|
3. Parse XML → `dmarc_reports` table (domain, reporter, counts, date).
|
||||||
|
4. Panel reads SQLite; links from domain DNS card.
|
||||||
|
|
||||||
|
May share port-25 plumbing with [inbound-relay.md](inbound-relay.md) but must
|
||||||
|
remain a separate, opt-in feature that does not forward mail upstream.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Operator can point `rua=` at an address SelfPost accepts and see parsed
|
||||||
|
summaries in the panel within one reporting cycle.
|
||||||
|
- With the feature off, outbound-only behaviour is unchanged.
|
||||||
|
- Documented in [guide.md](../guide.md); migrations are backward-compatible.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
|
||||||
|
- Report volume and retention (mitigate: caps + pruning).
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# Plan: domain-admin (domain administrator role)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** target bump **1.x** MINOR, given a compatible migration of the
|
||||||
|
current administrator into a global one.
|
||||||
|
**Order:** recommended after [web-split](web-split.md) (done), before
|
||||||
|
[inbound-relay](inbound-relay.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Today the panel has exactly one subject: `RequireAuth` is a boolean gate, not a
|
||||||
|
role ([web.go](../../internal/web/web.go) — the
|
||||||
|
`mux.Handle("/", s.auth.RequireAuth(authed))` wrapper), and the session carries
|
||||||
|
nothing beyond the fact of being signed in.
|
||||||
|
|
||||||
|
Two panel roles:
|
||||||
|
|
||||||
|
| Role | Scope |
|
||||||
|
|------|-------|
|
||||||
|
| **global** | Full panel except nothing new — same powers as today's single admin |
|
||||||
|
| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
|
||||||
|
|
||||||
|
For each assigned domain, a domain-admin can:
|
||||||
|
|
||||||
|
- applications (create, sender mode, password regeneration, delete, L2 limit);
|
||||||
|
- DKIM/DNS status and recheck;
|
||||||
|
- per-domain DMARC `rua=` (inherit / none / custom) — full control on the
|
||||||
|
domain page;
|
||||||
|
- send log filtered to assigned domains;
|
||||||
|
- domain export (encrypted `.spde` optional, same as today);
|
||||||
|
- domain-level L2 rate limit.
|
||||||
|
|
||||||
|
What stays **global-only** (domain-admin gets 404 or redirect):
|
||||||
|
|
||||||
|
- adding and removing domains;
|
||||||
|
- domain import;
|
||||||
|
- creating/editing/deleting panel users and assigning domains;
|
||||||
|
- `/reload`;
|
||||||
|
- full backup (`/backup` — all of `/data` including every domain's `sasldb2`);
|
||||||
|
- mail queue (`/mail-queue*`);
|
||||||
|
- system log tail (`/system-log*`);
|
||||||
|
- status page (`/status*`) — server-wide health, queue summary, reload, DNS
|
||||||
|
recheck of the **hostname**; same treatment as queue and system log.
|
||||||
|
|
||||||
|
Domain-admin **self-service** on `/account`: username and password only (not
|
||||||
|
global DMARC report email).
|
||||||
|
|
||||||
|
## Why this extends v1.0
|
||||||
|
|
||||||
|
[product.md](../product.md) puts "multiple panel users, roles" out of scope
|
||||||
|
(one administrator). A second subject is a deliberate widening of the project's
|
||||||
|
boundary, as inbound-relay is.
|
||||||
|
|
||||||
|
The cost is phase-sized, not patch-sized:
|
||||||
|
|
||||||
|
- a users table and their binding to domains;
|
||||||
|
- the role in the session;
|
||||||
|
- authorisation in every handler (not only on the route — today `{id}`/`{aid}`
|
||||||
|
are checked for nothing beyond existence);
|
||||||
|
- reworking first-run setup and password change for several users;
|
||||||
|
- accounting for the new subject in backup and domain export.
|
||||||
|
|
||||||
|
*(The earlier wording of this item — "2FA and multiple administrators" — has
|
||||||
|
been replaced: 2FA is off the table, and "multiple administrators" is narrowed
|
||||||
|
to one specific role, because what is needed is not a second all-powerful admin
|
||||||
|
but limited access for the owner of one or several domains, with the list set
|
||||||
|
by the global administrator.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema and migration
|
||||||
|
|
||||||
|
**New migration** `0005_panel_users.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||||
|
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_domains (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, domain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate existing administrator → global user (idempotent guard via admin count).
|
||||||
|
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||||
|
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||||
|
FROM admin WHERE id = 1;
|
||||||
|
|
||||||
|
DROP TABLE admin;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sessions:** keep `sessions.username` (no schema change). On login and
|
||||||
|
`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session
|
||||||
|
after username change behaves as today (`Lookup` fails → redirect login).
|
||||||
|
|
||||||
|
**Backup/restore:** full backup already snapshots `selfpost.db` via
|
||||||
|
`VACUUM INTO`; users and bindings restore with the DB. No manifest format change
|
||||||
|
required (same `selfpost-full-backup`).
|
||||||
|
|
||||||
|
**DMARC two levels:**
|
||||||
|
|
||||||
|
- **Global** `users.dmarc_report_email` — only on global user's `/account`;
|
||||||
|
default `rua=` when a domain uses *inherit*.
|
||||||
|
- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page
|
||||||
|
(existing handler); domain-admin never sees the global default field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principal model
|
||||||
|
|
||||||
|
Request context carries a `Principal` (in `internal/web/auth`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Role string // "global" | "domain_admin"
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Role Role
|
||||||
|
Domains []int64 // assigned domain IDs; empty for global (meaning "all")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers:
|
||||||
|
|
||||||
|
- `CurrentPrincipal(r)` — from context;
|
||||||
|
- `IsGlobal(p)` — `p.Role == "global"`;
|
||||||
|
- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`;
|
||||||
|
- `CanAccessApp(p, app)` — `CanAccessDomain(p, app.DomainID)`.
|
||||||
|
|
||||||
|
`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after
|
||||||
|
existence check; return 404 (not 403) to avoid leaking IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route matrix
|
||||||
|
|
||||||
|
| Method | Path | global | domain_admin |
|
||||||
|
|--------|------|--------|--------------|
|
||||||
|
| GET | `/` | → `/status` | → `/domains` |
|
||||||
|
| GET | `/status`, `/status/fragment` | yes | **no** (404) |
|
||||||
|
| POST | `/status/recheck` | yes | **no** |
|
||||||
|
| GET | `/domains` | all domains | assigned only |
|
||||||
|
| POST | `/domains` | yes | **no** |
|
||||||
|
| POST | `/domains/import` | yes | **no** |
|
||||||
|
| GET | `/domains/{id}` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/dns-recheck` | yes | assigned |
|
||||||
|
| GET/POST | `/domains/{id}/delete` | yes | **no** |
|
||||||
|
| POST | `/domains/{id}/applications` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/ratelimit` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/dmarc` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/export` | yes | assigned |
|
||||||
|
| POST | `/applications/{aid}/*` | yes | if app in assigned domain |
|
||||||
|
| POST | `/reload` | yes | **no** |
|
||||||
|
| GET/POST | `/account` | username, password, global DMARC email | username, password only |
|
||||||
|
| GET/POST | `/backup` | yes | **no** |
|
||||||
|
| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains |
|
||||||
|
| GET | `/mail-queue*` | yes | **no** |
|
||||||
|
| GET | `/system-log*` | yes | **no** |
|
||||||
|
| GET | `/users` | list users | **no** |
|
||||||
|
| GET/POST | `/users/new` | create user | **no** |
|
||||||
|
| GET/POST | `/users/{uid}` | edit/delete user | **no** |
|
||||||
|
| POST | `/logout` | yes | yes |
|
||||||
|
|
||||||
|
**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned
|
||||||
|
domain set; dropdowns list only assigned domains/apps; reject `domain` query
|
||||||
|
param outside assignment; `HandleDelivery` checks log row's `domain` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User management UI (global only)
|
||||||
|
|
||||||
|
New routes under `/users`:
|
||||||
|
|
||||||
|
- **List** — username, role, assigned domain names (or "all" for global).
|
||||||
|
- **Create** — username, password, role (`domain_admin` default), multi-select
|
||||||
|
domains (required when role is `domain_admin`).
|
||||||
|
- **Edit** — change password (optional), reassign domains, delete user.
|
||||||
|
- **Guards:** cannot delete the last `global` user; cannot demote self to
|
||||||
|
`domain_admin` without another global user; domain-admin role cannot access
|
||||||
|
these routes.
|
||||||
|
|
||||||
|
Templates: `users.html`, `user_form.html`; nav link visible only for global
|
||||||
|
users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth / setup / sessions
|
||||||
|
|
||||||
|
- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global**
|
||||||
|
user via `CreateGlobalUser`; `AdminExists` → `UserExists`.
|
||||||
|
- **Login:** authenticate against `users` by username + bcrypt.
|
||||||
|
- **Password change:** per-user `UpdateUser`; domain-admin cannot change
|
||||||
|
another user's password.
|
||||||
|
- **Session rename / destroy others:** unchanged behaviour keyed by username.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
[layout.html](../../internal/web/view/templates/layout.html) `nav` template:
|
||||||
|
|
||||||
|
- **global:** all items today (status, domains, deliveries, mail queue, system
|
||||||
|
log, backup, settings) + **Users**.
|
||||||
|
- **domain_admin:** domains, deliveries, settings only.
|
||||||
|
|
||||||
|
Pass `IsGlobal` (or `Principal`) into every rendered page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR);
|
||||||
|
note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in
|
||||||
|
this phase.
|
||||||
|
- **Export encryption:** optional password on domain export remains; full backup
|
||||||
|
encryption trigger ("second administrator") is satisfied by domain-admin
|
||||||
|
existing — no change required.
|
||||||
|
- **Authorization tests:** table-driven tests for global vs domain-admin on
|
||||||
|
representative handlers; explicit `{aid}` cross-domain mutation blocked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings).
|
||||||
|
2. Auth: login against `users`, `Principal` in context, setup creates global user.
|
||||||
|
3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`.
|
||||||
|
4. Route guards: global-only middleware or per-handler checks.
|
||||||
|
5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
|
||||||
|
6. User management handlers + templates.
|
||||||
|
7. Nav visibility + default redirect (`/`).
|
||||||
|
8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- A global administrator and a domain-admin with different rights both work
|
||||||
|
through the panel; the domain-admin cannot reach past the **assigned**
|
||||||
|
domains;
|
||||||
|
- the current single admin migrates into a global one without losing access;
|
||||||
|
- backup/restore accounts for users and their bindings;
|
||||||
|
- `build`/`vet`/`test`/image green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
|
||||||
|
else's domain;
|
||||||
|
- breaking setup or backup — that would be a semver major, not 1.x.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Plan: inbound-relay (inbound relay)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
||||||
|
the implementation lands (do not fix a major in advance).
|
||||||
|
**Order:** recommended after [web-split](web-split.md) and
|
||||||
|
[domain-admin](domain-admin.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The ability to accept mail on port 25 for explicitly configured domains and
|
||||||
|
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
|
||||||
|
a **module disabled by default** that changes neither the behaviour nor the
|
||||||
|
attack surface of the base outbound relay.
|
||||||
|
|
||||||
|
## What it is for (scenarios)
|
||||||
|
|
||||||
|
- **Backup-MX** — accept mail while the domain's primary mail server is
|
||||||
|
temporarily unreachable, and hand it over when it comes back.
|
||||||
|
- **A front for a server without a public IP** — the operator runs their own
|
||||||
|
mail server which, for whatever reason, **cannot accept mail from the
|
||||||
|
internet itself** (no static or public IP, behind NAT, a private address,
|
||||||
|
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
|
||||||
|
PTR, acts as the domain's public entry node (the MX points at it) and
|
||||||
|
forwards mail to that internal or otherwise unreachable server.
|
||||||
|
|
||||||
|
## Scope boundary (critical — what this is NOT)
|
||||||
|
|
||||||
|
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
|
||||||
|
forwarding (relay/forward) to an upstream (`relay_domains` +
|
||||||
|
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
|
||||||
|
with no local delivery.
|
||||||
|
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
|
||||||
|
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
|
||||||
|
**neither implements nor bundles** an anti-spam or anti-virus engine
|
||||||
|
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
|
||||||
|
filtering onto the backend either (see the "Anti-spam" section below): it
|
||||||
|
provides an attachment point for an external filter.
|
||||||
|
|
||||||
|
## Why as an option / plugin
|
||||||
|
|
||||||
|
- Accepting on port 25 changes the threat model (open relay for inbound,
|
||||||
|
backscatter, spam ingress). So it is **off** by default behind the
|
||||||
|
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
|
||||||
|
the operator.
|
||||||
|
- Isolation: separate SQLite tables, separate panel handlers and pages, a
|
||||||
|
separate branch of config generation. With the flag off, the inbound
|
||||||
|
listener, the tables and the UI are absent — the base outbound path is
|
||||||
|
byte-for-byte unchanged.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
|
||||||
|
the inbound service and its config from panel state the same way the rest of
|
||||||
|
the config is generated (`postfix-config.sh`).
|
||||||
|
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
|
||||||
|
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
|
||||||
|
SASL is **not** offered and sending outwards is **not** allowed — inbound
|
||||||
|
only, for `relay_domains`.
|
||||||
|
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
|
||||||
|
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
|
||||||
|
**only** for domains in `relay_domains` and **only** for known recipients
|
||||||
|
(`relay_recipient_maps`); everything else gets
|
||||||
|
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
|
||||||
|
accepting "for anyone", is impossible.
|
||||||
|
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
|
||||||
|
recipient at RCPT stage) so that bounces to non-existent addresses are never
|
||||||
|
generated.
|
||||||
|
- **The panel manages:** the list of inbound domains; for each one the upstream
|
||||||
|
destination (`host:port`, transport), an optional list of valid recipients,
|
||||||
|
and optional TLS to the upstream. Strict validation of domain, host and port
|
||||||
|
(whitelist), injection-safe writing of map files (as with
|
||||||
|
`sender_login_maps` in Phase 4), `os/exec` without a shell
|
||||||
|
([security.md](../security.md)).
|
||||||
|
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
|
||||||
|
someone else's inbound mail). The journal-milter can optionally be reused for
|
||||||
|
an inbound journal (extra work), or the inbound path can go without it in the
|
||||||
|
first stage; fail-open behaviour is preserved.
|
||||||
|
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
|
||||||
|
`message_size_limit` on the inbound smtpd.
|
||||||
|
|
||||||
|
## Anti-spam (important, but optional)
|
||||||
|
|
||||||
|
This is a valuable option, but it is **not mandatory**: some operators will be
|
||||||
|
perfectly served by **blind forwarding without filtering** — when the backend
|
||||||
|
can filter on content itself, when the upstream is trusted, or when the volume
|
||||||
|
and risk are low. So the anti-spam hook is **off** by default (an empty
|
||||||
|
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
|
||||||
|
it.
|
||||||
|
|
||||||
|
What matters is something else: where filtering is technically possible. With a
|
||||||
|
"blind" relay the destination backend sees **SelfPost's** address as the
|
||||||
|
connecting IP, not the original sender's, so everything on the backend that
|
||||||
|
depends on the origin IP breaks (DNSBL and reputation are checked against
|
||||||
|
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
|
||||||
|
SPF). **The only point where the real client IP is still visible is the inbound
|
||||||
|
hop at SelfPost** — so for those who need filtering, it has to be *attachable
|
||||||
|
right here*, not delegated to a backend that has already lost the information.
|
||||||
|
|
||||||
|
The attachment design:
|
||||||
|
|
||||||
|
- **The anti-spam engine is a separate optional container** (rspamd or
|
||||||
|
similar), which the operator runs **only if this option is wanted** (the same
|
||||||
|
principle as the reverse proxy — a separate container outside the SelfPost
|
||||||
|
image). SelfPost **neither contains nor starts it** — the image and the "one
|
||||||
|
container, three processes" principle are unchanged, and
|
||||||
|
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
|
||||||
|
does not implement anti-spam).
|
||||||
|
- **SelfPost provides the attachment point:** a milter hook on the inbound
|
||||||
|
smtpd. The engine's address is set via env (for example,
|
||||||
|
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
|
||||||
|
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
|
||||||
|
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
|
||||||
|
the true origin. `milter_default_action` for that milter is configurable
|
||||||
|
(fail-open vs tempfail); the default is to be decided during implementation.
|
||||||
|
- **A native backstop with no dependencies:** on that same inbound hop,
|
||||||
|
Postfix's own origin-IP facilities are available — `reject_rbl_client`
|
||||||
|
(DNSBL) and HELO/PTR checks — and they work even without an external
|
||||||
|
container. Plus preserving authentication results for downstream through ARC
|
||||||
|
or `Received`, where part of the filtering does remain on the backend.
|
||||||
|
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
|
||||||
|
alternative reverse-proxy fragments) — the container comes up with the stack
|
||||||
|
only when the option is enabled.
|
||||||
|
- **Persistence:** new tables and map files under `/data` — they land in the
|
||||||
|
full backup automatically (Phase 9). Domain export/import can be extended
|
||||||
|
with the inbound configuration — optional, to be flagged.
|
||||||
|
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
|
||||||
|
server (unlike outbound, where no MX is required) — to be reflected in the
|
||||||
|
README's DNS section.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
[security.md](../security.md): server-side input validation, escaped writes to
|
||||||
|
config files, `exec` without interpolation, no open relay, protection against
|
||||||
|
backscatter.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
|
||||||
|
25 for that domain is forwarded to the given upstream; mail for unconfigured
|
||||||
|
domains or recipients is rejected (not an open relay, no backscatter); with
|
||||||
|
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
|
||||||
|
with the real origin IP (verified with a sidecar container), and with it empty
|
||||||
|
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
|
||||||
|
port, tables and UI are absent and the base outbound relay is unchanged;
|
||||||
|
`build`/`vet`/`test`/image green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- open relay / backscatter — removed by `relay_domains` +
|
||||||
|
`relay_recipient_maps` + `reject_unauth_destination`;
|
||||||
|
- the loss of the origin IP for filtering on the backend when forwarding —
|
||||||
|
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
|
||||||
|
where the origin IP is still visible;
|
||||||
|
- port 25 accepting mail widens the attack surface (off by default);
|
||||||
|
- semver: if the contract turns out incompatible (ports, backup, behaviour with
|
||||||
|
the flag off) a major `2.x` is possible; the decision comes after the
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
**External deployment dependency:** the optional anti-spam container — outside
|
||||||
|
the SelfPost image, brought up by the operator when the option is enabled.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
A finished outbound path (already implemented). Agreement obtained — see the
|
||||||
|
status above.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Plan: visual-style
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** no bearing on semver — presentation only, no schema and no route
|
||||||
|
changes.
|
||||||
|
**Order:** independent of the feature roadmap; may be taken up between feature
|
||||||
|
items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring the control panel's surface in line with the mark that was approved in
|
||||||
|
[selfpost-proof.html](../assets/selfpost-proof.html): its palette, its
|
||||||
|
typography, and the plainness of its components. Today the panel is a default
|
||||||
|
blue-on-cool-grey admin theme standing next to a warm brick stamp, so the mark
|
||||||
|
reads as pasted onto someone else's page.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- `internal/web/view/static/panel.css` — colour tokens, typography, spacing,
|
||||||
|
every component rule.
|
||||||
|
- `internal/web/view/static/` — three self-hosted font files.
|
||||||
|
- Templates, only where a class has to be added or a wrapper introduced for a
|
||||||
|
rule to have something to attach to.
|
||||||
|
- `NOTICE` — the OFL attribution the font files oblige.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
- Any change to what a page does, which pages exist, or what an operator has to
|
||||||
|
click. No new features, no copy rewriting.
|
||||||
|
- The navigation's position and the two-column shell. The proof's panel mock
|
||||||
|
shows a horizontal bar on a dark header; the panel's left column also carries
|
||||||
|
the per-page section index (`.sections` plus the scroll-spy in `panel.js`),
|
||||||
|
which that layout has nowhere to put. Keeping the column is a deliberate
|
||||||
|
divergence from the mock, not an oversight.
|
||||||
|
- The mark files themselves (`logo.svg`, `logo-compact.svg`, `favicon.*`) —
|
||||||
|
already drawn, already converted to outlines.
|
||||||
|
|
||||||
|
## Constraint that shapes everything
|
||||||
|
|
||||||
|
The panel's Content-Security-Policy is a plain `default-src 'self'` with no
|
||||||
|
inline-style exemption ([security.md](../security.md)). Every rule lives in
|
||||||
|
`panel.css`; a `style="..."` attribute in a template is blocked and silently
|
||||||
|
does nothing. Self-hosted fonts are served from the panel's own origin and are
|
||||||
|
therefore already covered — no CSP change is needed, and none may be made.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
IBM Plex, self-hosted. The mark is Plex converted to outlines, so the panel
|
||||||
|
setting its own name in Segoe UI or Cantarell is the seam this whole item
|
||||||
|
exists to close.
|
||||||
|
|
||||||
|
| File | Covers | Size |
|
||||||
|
|---|---|---|
|
||||||
|
| `static/ibm-plex-sans.woff2` | variable, weights 100–700, latin | 45.7 KB |
|
||||||
|
| `static/ibm-plex-mono-400.woff2` | mono regular, latin | 14.8 KB |
|
||||||
|
| `static/ibm-plex-mono-600.woff2` | mono semibold, latin | 15.7 KB |
|
||||||
|
|
||||||
|
76 KB in total, in a 20 MB binary. The variable file replaces what would
|
||||||
|
otherwise be five static weights and lets the scale below use 300 and 500
|
||||||
|
without paying per weight.
|
||||||
|
|
||||||
|
Monospace is the one the operator actually reads: DKIM records, `mail.log`
|
||||||
|
lines, application logins, socket paths, generated passwords. `ui-monospace`
|
||||||
|
resolves to Consolas, SF Mono or DejaVu Sans Mono depending on the operator's
|
||||||
|
machine, and those differ in advance width — the six-column send log wraps
|
||||||
|
differently for each. A shipped mono makes those tables one layout.
|
||||||
|
|
||||||
|
| Role | Family | Size | Weight |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Body | sans | 15px / 1.5 | 400 |
|
||||||
|
| `h1` | sans | 1.55rem, tracking −0.01em | 300 |
|
||||||
|
| `h2` | sans | 1.05rem | 600 |
|
||||||
|
| `label` | sans | 0.9rem | 600 |
|
||||||
|
| Nav entry / active | sans | 0.95rem | 400 / 600 |
|
||||||
|
| `th` | **mono**, uppercase, tracking 0.08em | 0.75rem | 500 |
|
||||||
|
| `.st` status badge | **mono** | 0.78rem | 500 |
|
||||||
|
| `.code`, `.mono`, `.metric` | **mono** | 0.85rem | 400 |
|
||||||
|
|
||||||
|
`font-display: swap`, so a cold load shows the system stack for a frame rather
|
||||||
|
than blank text.
|
||||||
|
|
||||||
|
## Colour tokens
|
||||||
|
|
||||||
|
Names stay as they are wherever they already exist: the dark scheme reassigns
|
||||||
|
the same custom properties, which is why no rule in the file needs
|
||||||
|
`!important`. Warm neutrals replace the cool greys; brick becomes the accent.
|
||||||
|
|
||||||
|
| Token | Light | Dark |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#F4F2ED` | `#16181B` |
|
||||||
|
| `--fg` | `#12161C` | `#E9E6E0` |
|
||||||
|
| `--muted` | `#6B7684` | `#9AA1A9` |
|
||||||
|
| `--card-bg` | `#FFFFFF` | `#1D2024` |
|
||||||
|
| `--border` | `#DEDCD7` | `#2C2F34` |
|
||||||
|
| `--control-border` | `#CBC8C1` | `#3A3E44` |
|
||||||
|
| `--input-bg` | `#FFFFFF` | `#14161A` |
|
||||||
|
| `--code-bg` | `#EFEDE9` | `#14161A` |
|
||||||
|
| `--surface-bg` | `#EAE7E0` | `#23262B` |
|
||||||
|
| `--accent-fill` / `--on-accent` | `#7A3B2E` / `#FFFFFF` | `#8E4535` / `#FFFFFF` |
|
||||||
|
| `--accent-text` | `#7A3B2E` | `#CE7B66` |
|
||||||
|
| `--nav-active-bg` | `#EDE4DE` | `#2A1F1B` |
|
||||||
|
|
||||||
|
Brick splits into a fill and a text value because `#7A3B2E` on `#16181B` is
|
||||||
|
about 2:1 — unreadable as a dark-scheme link. The fill lightens just enough to
|
||||||
|
keep white on it above 4.5:1; the text value lightens further.
|
||||||
|
|
||||||
|
Status families (`--st-ok-*`, `--st-warn-*`, `--st-error-*`, `--st-unknown-*`),
|
||||||
|
the flash, the credential card and `--danger-*` keep their hues and are only
|
||||||
|
warmed to sit on paper. The one thing to watch is brick against `st-error` red:
|
||||||
|
the proof rejected several candidate colours precisely so that the mark would
|
||||||
|
not read as a status, and the same test now applies to every brick button
|
||||||
|
standing in a row of `error` badges.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Everything already in `panel.css`, in the order it appears there: card, form
|
||||||
|
controls, buttons (filled, outlined, danger), flash, table, status badge,
|
||||||
|
`.code`, nav (brand, links, sections, session), application list and its
|
||||||
|
disclosure panels, credential card, status page meters and facts, delivery
|
||||||
|
timeline, log tables, split layout, encrypt fields, footer.
|
||||||
|
|
||||||
|
Two component-level changes rather than pure repaints, both forced by the
|
||||||
|
accent:
|
||||||
|
|
||||||
|
- Row actions (`td.actions a.danger`, `Delete`) become outlined instead of
|
||||||
|
filled. A filled red button in a table row next to a filled brick button
|
||||||
|
reads as one block of colour.
|
||||||
|
- Nav entries carry the active state as brick text on a warm tint rather than
|
||||||
|
the current blue tint.
|
||||||
|
|
||||||
|
## Order of work
|
||||||
|
|
||||||
|
1. Fonts into `static/`, `@font-face` and the type scale in `panel.css`,
|
||||||
|
`NOTICE` attribution. Nothing else changes shape.
|
||||||
|
2. Token block: light and dark, both schemes in one pass.
|
||||||
|
3. Chrome: `layout.html`'s nav, footer, shell.
|
||||||
|
4. Signed-out pages: `login`, `setup` — the mark and one card, where the seam
|
||||||
|
is worst.
|
||||||
|
5. `dashboard`, `domain_detail`, `domain_delete`.
|
||||||
|
6. `deliveries`, `deliveries_rows`, `delivery`.
|
||||||
|
7. `status` + `status_body`, `mail_queue*`, `system_log*`.
|
||||||
|
8. `backup`, `account`, `users`, `user_form`, `encrypt_fields`.
|
||||||
|
9. `CHANGELOG.md` under `[Unreleased]`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Every page rendered locally and screenshotted in both schemes before and
|
||||||
|
after (`panel.exe` on Windows, headless Edge), including a 375px-wide pass —
|
||||||
|
the nav column and the wide tables are where a repaint breaks layout.
|
||||||
|
- Contrast: body text and every status badge at 4.5:1 or better against its own
|
||||||
|
background, UI borders at 3:1. Brick on white is 7.3:1 by the proof's own
|
||||||
|
measurement; the dark-scheme values above are the ones to re-check.
|
||||||
|
- `go build ./... && go vet ./... && go test ./...`; the template guards in
|
||||||
|
`internal/web/view/templates_test.go` must stay green, and the static-asset
|
||||||
|
ETag test grows to cover the three font files.
|
||||||
|
- No `style=` attribute anywhere in `templates/` — the CSP would drop it.
|
||||||
|
|
||||||
|
## What is done
|
||||||
|
|
||||||
|
The restyle itself landed in `652f1fe`, with the table-wrapping fixes it
|
||||||
|
surfaced in `f44f533`. Every page in the order above was rendered in both
|
||||||
|
schemes from a panel running locally and checked: the signed-out pair, the
|
||||||
|
domain list empty and with three domains, the whole domain page (credential
|
||||||
|
card, DKIM/SPF/DMARC, DNS status, applications, rate limit, export, danger
|
||||||
|
zone), the delete confirmation, the send log with rows, a single delivery,
|
||||||
|
status, mail queue, system log, backup, settings, users and the user form.
|
||||||
|
|
||||||
|
Two of those need standing in for what the container provides: `supervisorctl`,
|
||||||
|
`saslpasswd2` and `postmap` stubs on `PATH`, `postfix/`, `opendkim/keys/` and
|
||||||
|
`sasl/` created inside the data dir by hand, and rows seeded into `send_log` —
|
||||||
|
without them the domain, application and send-log pages do not exist locally.
|
||||||
|
|
||||||
|
## Outstanding
|
||||||
|
|
||||||
|
Nothing here blocks the item; each is written down so it is not rediscovered.
|
||||||
|
|
||||||
|
1. **Send-log status is bare text**, while every other status in the panel is a
|
||||||
|
`.st` badge. Making it one is not a repaint: it needs a mapping from
|
||||||
|
`sent`/`queued`/`deferred`/`bounced` onto the four badge colours, which is a
|
||||||
|
judgement about severity (is `deferred` a warning?) rather than a style.
|
||||||
|
**Needs a decision before it is written.**
|
||||||
|
2. **The panel overflows horizontally at 375px** — `main` and its cards render
|
||||||
|
wider than the window and the page scrolls sideways. Reproduced with the
|
||||||
|
stylesheet at `f59befd` too, so it predates this work; tracked separately.
|
||||||
|
Removing the navigation does not fix it, so it is in `main`/`.card`, not in
|
||||||
|
the bar the `@media (max-width: 66rem)` block lies down.
|
||||||
|
3. **Three views were only ever seen empty**: the mail queue with entries, the
|
||||||
|
system log with lines, and a delivery's own `mail.log` lines (`table.log`).
|
||||||
|
All three need a running Postfix, so they are a test-server check, not a
|
||||||
|
local one. `table.log` is the only restyled component with no screenshot
|
||||||
|
behind it.
|
||||||
|
4. **CSP and the font ETags were verified locally from saved files**, where no
|
||||||
|
policy header is served at all. That the panel's own `default-src 'self'`
|
||||||
|
admits the WOFF2 files, and that they come back with a validator, is a
|
||||||
|
test-server check.
|
||||||
|
5. **`font-display: swap` has never been observed** — every render had the fonts
|
||||||
|
already on disk. Worth one cold load over the network to see how long the
|
||||||
|
system stack is on screen.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The panel and the mark read as one design in both schemes, at the reading
|
||||||
|
measure and on the wide data pages.
|
||||||
|
- Nothing an operator does changed: same pages, same controls, same copy.
|
||||||
|
- Fonts are served from the panel's own origin under the unchanged CSP, and the
|
||||||
|
image works with no network access.
|
||||||
|
- `NOTICE` credits IBM Plex (OFL-1.1); build, vet and tests are green.
|
||||||
|
- The three views in **Outstanding** 3 are seen with real data on the test
|
||||||
|
server, and 4 is confirmed there.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Visual regression across 21 templates.** The panel has pages that are only
|
||||||
|
reachable mid-workflow (the credential card, the delete confirmation, the
|
||||||
|
encrypt fields). Mitigation: the page order above is a checklist, and each
|
||||||
|
step is screenshotted rather than assumed.
|
||||||
|
- **Brick against the error red.** If the two fight in a real row of the send
|
||||||
|
log, the accent gets pulled back to the mark and the buttons stay neutral —
|
||||||
|
the mark's colour is fixed, the panel's accent is the negotiable one.
|
||||||
|
- **Thin weights on dark.** `h1` at 300 is the one place a variable font makes
|
||||||
|
it easy to go too light; check it on the dark scheme before keeping it.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 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.
|
||||||
@@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep:
|
|||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
|
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
||||||
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
|
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
||||||
|
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
||||||
|
implementation. Items still marked *candidate* in the roadmap require explicit
|
||||||
|
approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +1,152 @@
|
|||||||
# Дорожная карта: SelfPost 2.x.x
|
# Roadmap: open work (1.x+)
|
||||||
|
|
||||||
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
|
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
|
||||||
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
|
only after explicit agreement ([product.md](product.md),
|
||||||
только после явного согласования ([product.md](product.md),
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
|
design lives in [plans/](plans/). Items marked `candidate` need an OK before
|
||||||
[product.md](product.md) явно исключает часть этого объёма (приём входящей
|
any code is written.
|
||||||
почты; несколько пользователей/роли), поэтому включение — сознательное
|
|
||||||
расширение границ проекта, а не доработка по своей инициативе. Присутствие
|
|
||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
|
||||||
решением.
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Reading this from outside the project:** nothing here is a commitment or a
|
||||||
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
release promise. There are no dates, the order is a recommendation rather than
|
||||||
[CHANGELOG.md](../CHANGELOG.md).
|
a schedule, and an item can be dropped or reshaped once its plan is written.
|
||||||
|
What the project *will not* do is a separate question, answered in
|
||||||
|
[product.md](product.md) — an item's absence from this file does not mean it is
|
||||||
|
planned but unlisted.
|
||||||
|
|
||||||
|
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
||||||
|
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||||||
|
an explicit break.
|
||||||
|
|
||||||
|
**Process:** [development.md](development.md). The history of closed phases is
|
||||||
|
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## Index
|
||||||
|
|
||||||
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
| ID | Topic | Status | Plan |
|
||||||
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
|---|---|---|---|
|
||||||
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
|
||||||
|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | candidate | — |
|
||||||
|
| visual-style | Panel visual style | **agreed** | [plans/visual-style.md](plans/visual-style.md) |
|
||||||
|
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||||
|
|
||||||
| Тема | Итог |
|
**Recommended order** (not binding): **domain-admin →
|
||||||
|---|---|
|
inbound-relay** — role-wide authorisation first, then the inbound relay vertical
|
||||||
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
slice. Deviating is allowed; there are no hard phases here.
|
||||||
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
|
||||||
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
|
||||||
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
|
||||||
|
|
||||||
Открытая работа дальше — только секции 2.x ниже.
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
|
the checklist in its linked plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
|
## inbound-relay
|
||||||
|
|
||||||
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
|
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
||||||
|
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
|
||||||
|
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
**Зачем это нужно (сценарии):**
|
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
|
||||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
|
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
|
||||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
|
engine stays outside the image, only the attachment point is provided.
|
||||||
|
|
||||||
**Граница объёма (критично — что это НЕ):**
|
**Done when:** see the criteria in
|
||||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
|
[plans/inbound-relay.md](plans/inbound-relay.md).
|
||||||
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
|
|
||||||
|
|
||||||
**Почему как опция/плагин:**
|
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
||||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
a wider attack surface (port 25 accepting mail).
|
||||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
**Order:** recommended after [domain-admin](plans/domain-admin.md).
|
||||||
|
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
||||||
**Что делать:**
|
implementation lands.
|
||||||
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
|
|
||||||
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
|
|
||||||
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
|
|
||||||
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
|
||||||
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
|
|
||||||
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
|
|
||||||
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
|
|
||||||
|
|
||||||
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
|
|
||||||
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
|
|
||||||
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
|
|
||||||
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
|
|
||||||
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
|
|
||||||
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
|
|
||||||
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
|
|
||||||
|
|
||||||
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
|
||||||
|
|
||||||
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
|
||||||
|
|
||||||
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
|
|
||||||
|
|
||||||
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Роль администратора домена — кандидат на 2.x
|
## domain-admin
|
||||||
|
|
||||||
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
**Goal:** 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`).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
|
**Boundary:** an extension of v1.0 — [product.md](product.md) fixes a single
|
||||||
панели, роли» к out of scope (один администратор), поэтому появление второго
|
administrator. Not a second all-powerful admin, but limited access to the
|
||||||
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
|
assigned domains (one or several).
|
||||||
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
|
|
||||||
|
|
||||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
|
**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.md` — кандидат на 2.x
|
## contributing
|
||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
моделей по типу работы, протокол коммитов, требование
|
before a PR, the commit protocol; [development.md](development.md) links to it
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
rather than repeating it.
|
||||||
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
|
||||||
работа, русский).
|
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
**Boundary:** process documentation; worth writing once there is an external
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
flow of PRs.
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
|
||||||
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
|
||||||
русском) и первый внешний интерес после публикации релиза.
|
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
**Done when:** the file is in the root and development.md does not duplicate
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
it.
|
||||||
дублирует его, а ссылается.
|
|
||||||
|
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||||||
|
priority.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
|
## visual-style
|
||||||
|
|
||||||
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
|
**Goal:** refresh the control panel's visual design — typography, colour tokens,
|
||||||
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
|
spacing, and component styling — without changing operator workflows or panel
|
||||||
лежат хендлеры всех разделов панели, сессии,
|
behaviour.
|
||||||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
|
||||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
|
||||||
панели.
|
|
||||||
|
|
||||||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
**Boundary:** presentation only (`panel.css`, templates, static assets); no new
|
||||||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
features. Styling must stay compatible with the panel CSP — rules live in
|
||||||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
`panel.css`, not inline (see [security.md](security.md) and the stylesheet
|
||||||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
header).
|
||||||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
|
||||||
2.x выше добавляют в него код — роль администратора домена приносит
|
**Done when:** see [plans/visual-style.md](plans/visual-style.md). The agreed
|
||||||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
direction is the mark's own — brick, warm paper, IBM Plex — taken from
|
||||||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
[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 (rules in
|
||||||
|
`panel.css`, never inline); visual regression across pages; the accent colour
|
||||||
|
must not read as a status badge.
|
||||||
|
**Order:** independent of the feature items; may be taken up between them.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dmarc-reports
|
||||||
|
|
||||||
|
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||||
|
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
|
||||||
|
the operator does not need an external DMARC service or a separate mailbox
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
|
||||||
|
inbound relay. A dedicated inbound path for report messages only; forensic
|
||||||
|
reports (`ruf=`) out of scope for v1.
|
||||||
|
|
||||||
|
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
||||||
|
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
||||||
|
storage and retention of parsed summaries; the `admin.dmarc_report_email` and
|
||||||
|
`domains.dmarc_rua` settings added in the DMARC template work must stay the
|
||||||
|
source of truth for `rua=` in DNS guidance.
|
||||||
|
|
||||||
|
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
||||||
|
inbound-relay depending on how port 25 acceptance is structured.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
|
||||||
разрезан, либо зафиксировано, что он остаётся плоским.
|
|
||||||
|
|||||||
@@ -1,177 +1,193 @@
|
|||||||
# Безопасность
|
# Security
|
||||||
|
|
||||||
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
|
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
|
||||||
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
|
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
||||||
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
defence-in-depth — `--` перед логином в argv `saslpasswd2`
|
login in the `saslpasswd2` argv
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** —
|
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** —
|
||||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
|
lost.
|
||||||
|
|
||||||
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
|
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
|
||||||
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
|
cookie with duplicate detection — Phase 14) is done; the history is in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
|
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
|
||||||
|
|
||||||
Продуктовые границы: [product.md](product.md). Устройство as-built:
|
Product boundaries: [product.md](product.md). As-built design:
|
||||||
[architecture.md](architecture.md).
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обязательные требования
|
## Mandatory requirements
|
||||||
|
|
||||||
Панель публична из интернета — пункты ниже **не опциональны**.
|
The panel is exposed to the internet — the items below are **not optional**.
|
||||||
|
|
||||||
### Первичная инициализация администратора
|
### First-run administrator setup
|
||||||
|
|
||||||
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
|
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
|
||||||
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
|
ready-made password hash.
|
||||||
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
|
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
|
||||||
настройки — перегенерация и новый вывод в лог.
|
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
|
||||||
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
|
unfinished, it is regenerated and logged again.
|
||||||
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
|
- Rate limiting on `/setup/<token>` per IP, separate from login.
|
||||||
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
|
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
|
||||||
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
|
being DoS-ed).
|
||||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
|
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
|
||||||
|
and no MD5.
|
||||||
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
### SASL-пароли приложений
|
### Application SASL passwords
|
||||||
|
|
||||||
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
|
- The panel **generates** the password on creation or reissue and shows it
|
||||||
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
|
**once**.
|
||||||
перевыпуск.
|
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||||
|
panel); a lost password can only be reissued.
|
||||||
|
|
||||||
### Ввод и конфигурация
|
### Input and configuration
|
||||||
|
|
||||||
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
|
- Server-side validation of addresses and domains (character whitelist);
|
||||||
защитой.
|
client-side validation does not count as protection.
|
||||||
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
|
- In address-list mode every address is checked to belong to the application's
|
||||||
- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского
|
domain before it is written.
|
||||||
ввода; аргументы отдельными элементами.
|
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||||
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
|
input; arguments are passed as separate elements.
|
||||||
|
- Writes to config files are escaped (no injection of Postfix directives).
|
||||||
|
|
||||||
### Аутентификация и сессии
|
### Authentication and sessions
|
||||||
|
|
||||||
- Rate limiting на логин (по IP, с блокировкой/задержкой).
|
- Rate limiting on login (per IP, with lockout or delay).
|
||||||
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
|
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||||
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
|
`SameSite`.
|
||||||
(`PANEL_SESSION_IDLE_DAYS`).
|
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||||
|
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||||
|
|
||||||
### Вывод и процесс
|
### Output and process
|
||||||
|
|
||||||
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
|
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||||
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
|
journal, themes).
|
||||||
группу `selfpost` и минимальные права.
|
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||||
|
is granted through the `selfpost` group with minimal permissions.
|
||||||
|
|
||||||
### Почтовый тракт (связанное с безопасностью)
|
### Mail path (security-relevant)
|
||||||
|
|
||||||
- **Не open relay** — только SASL; `reject_unauth_destination`;
|
- **Not an open relay** — SASL only; `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
|
`encrypt`).
|
||||||
при rate-limit логина; пусто = XFF игнорируется.
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
|
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
|
||||||
|
|
||||||
### Резервная копия и экспорт домена
|
### Backup and domain export
|
||||||
|
|
||||||
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
|
||||||
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
|
administrator's password hash; a domain export carries the DKIM key and
|
||||||
текстом (иначе перенос без пересоздания кредов невозможен).
|
**working** application passwords in the clear (otherwise a transfer without
|
||||||
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
|
recreating credentials would be impossible).
|
||||||
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
подменённый файл не открывается вместо тихого восстановления «хвоста».
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
|
a truncated or substituted file fails to open instead of silently restoring a
|
||||||
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
|
partial "tail". Format and wrapper:
|
||||||
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
|
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||||
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
|
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||||
шифрование по magic файла, а не по расширению.
|
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||||
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
|
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
|
||||||
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
|
bytes, not by extension.
|
||||||
аргументом (список процессов читается любым процессом контейнера).
|
- The password is never stored: without it the file cannot be recovered. In the
|
||||||
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
|
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||||
offline и подбирается без ограничений по времени.
|
`-password-file`, never as an argument (the process list is readable by any
|
||||||
|
process in the container).
|
||||||
|
- Minimum password length matches the administrator password (12): the file
|
||||||
|
sits offline and can be attacked without a time limit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Принятые риски
|
## Accepted risks
|
||||||
|
|
||||||
Принятый риск — решение с условием возврата, а не отложенная задача из
|
An accepted risk is a decision with a condition for revisiting it, not a
|
||||||
дорожной карты.
|
deferred item from the roadmap.
|
||||||
|
|
||||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
Принято сознательно: панель однопользовательская, админ выбирает браузер
|
deliberately: the panel is single-user, the administrator picks the browser,
|
||||||
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|
and a strict mode would not "protect" such a client, it would simply break the
|
||||||
панель. Ужесточение — одна строка в `originAllowed`
|
panel in it. Tightening is one line in `originAllowed`
|
||||||
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
вместо `true` в ветке «нет обоих заголовков».
|
instead of `true` in the "neither header present" branch.
|
||||||
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
|
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||||
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
revisit is a requirement for protection that holds regardless of the browser.
|
||||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
A token would not save the panel from XSS inside it either: code executing in
|
||||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
the panel's origin sends the request itself — against that, `html/template`
|
||||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
auto-escaping and CSP do the work, which is why templates must contain no
|
||||||
inline-скриптов и inline-стилей.
|
inline scripts and no inline styles.
|
||||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
backup at all, and a permanently undecryptable archive is worse than an
|
||||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
encryption mandatory is a second administrator (at which point "who
|
||||||
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
|
downloaded it" stops being one person).
|
||||||
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
|
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||||
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
|
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||||
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
|
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||||
дочитывается. Остаток — строки, delivery-строки которых потеряны
|
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||||
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
|
start. What remains are rows whose delivery lines are lost for good (the log
|
||||||
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
|
rotated past 14 files while the panel was down, or was deleted): the
|
||||||
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
|
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||||
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
|
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
|
||||||
не может подтвердить, не должна показываться как подтверждённая, а вечный
|
the status is a false negative. Accepted deliberately: a delivery the panel
|
||||||
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
|
cannot confirm must not be shown as confirmed, and a permanent `queued` is
|
||||||
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
|
indistinguishable from "in flight right now". Reconciliation does not run
|
||||||
читается. См. [architecture.md](architecture.md) § Log tailer.
|
until the tailer has read the log to the end, and touches nothing if
|
||||||
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
|
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
|
||||||
`/data/log` — `2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
|
tailer.
|
||||||
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
|
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
|
||||||
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
|
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||||
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
|
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||||
оставалась состоянием, а не диагностикой.
|
the file is inaccessible to others. The log holds envelope addresses and
|
||||||
|
client IPs, but neither message bodies nor headers; it is excluded from
|
||||||
|
backups (`log/` is skipped) so that a dump stays state rather than
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
## ADR: CSRF через проверку Origin, без токенов
|
## ADR: CSRF via origin checking, without tokens
|
||||||
|
|
||||||
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
|
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||||
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
|
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||||
инициированного сторонним сайтом в браузере залогиненного админа.
|
page apart from one initiated by a third-party site in the logged-in
|
||||||
|
administrator's browser.
|
||||||
|
|
||||||
**Решение.** `originAllowed` в
|
**Decision.** `originAllowed` in
|
||||||
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
сессии и встроенных в формы, нет.
|
There are no session-bound tokens embedded in forms.
|
||||||
|
|
||||||
**Почему не токены.** Панель однопользовательская (один администратор на
|
**Why not tokens.** The panel is single-user (one administrator per instance) —
|
||||||
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
|
the threat model does not include cross-user CSRF inside the panel itself, only
|
||||||
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
|
an external site making the administrator's browser send a request. The origin
|
||||||
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
|
check covers that without touching a single template: a token would need a
|
||||||
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
|
hidden field in roughly two dozen forms and synchronisation with every new form,
|
||||||
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
|
and it would still not protect against XSS inside the panel — code executing in
|
||||||
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
|
the panel's origin reads the token and sends the request itself. XSS is handled
|
||||||
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
|
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
|
||||||
обороны, не CSRF-токен.
|
not a CSRF token.
|
||||||
|
|
||||||
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
(по-настоящему старый браузер или webview с замороженным движком), остаётся
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
"Accepted risks" above. This is a deliberate choice not to break the panel in
|
||||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
браузера, или панель станет многопользовательской.
|
browser behaviour, or the panel becomes multi-user.
|
||||||
|
|
||||||
## Как этот список пополняется
|
## How this list grows
|
||||||
|
|
||||||
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||||
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
closes every finding in one of two ways: a fix before the tag, or an entry here
|
||||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
with its rationale and its condition for revisiting, like the items above.
|
||||||
Третьего варианта («посмотрели и ладно») нет.
|
There is no third option ("we looked at it and moved on").
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
// for application credentials (architecture.md § Mail path). The panel is the
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
// created and removed with the standard saslpasswd2 tool ("эквивалент
|
// created and removed with the standard saslpasswd2 tool.
|
||||||
// saslpasswd2", per the plan).
|
|
||||||
type SASLDB struct {
|
type SASLDB struct {
|
||||||
path string // sasldb2 file, under /data so it survives restarts
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ type Server struct {
|
|||||||
|
|
||||||
// Domain is the published-DNS state of one sending domain.
|
// Domain is the published-DNS state of one sending domain.
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
Name string
|
Name string
|
||||||
DKIM Result
|
DKIM Result
|
||||||
SPF Result
|
SPF Result
|
||||||
DMARC Result
|
DMARC Result
|
||||||
Overall health.Status
|
DMARCReportAuth Result // zero when external rua= is not used
|
||||||
CheckedAt time.Time
|
Overall health.Status
|
||||||
|
CheckedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
||||||
@@ -69,11 +70,12 @@ type Domain struct {
|
|||||||
// compares DNS against the key this server actually signs with. Hostname and
|
// compares DNS against the key this server actually signs with. Hostname and
|
||||||
// ServerIPs identify this server and come from a preceding Server check.
|
// ServerIPs identify this server and come from a preceding Server check.
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Name string
|
Name string
|
||||||
Selector string
|
Selector string
|
||||||
ExpectedDKIM string
|
ExpectedDKIM string
|
||||||
Hostname string
|
Hostname string
|
||||||
ServerIPs []string
|
ServerIPs []string
|
||||||
|
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||||
@@ -178,12 +180,19 @@ func (c *Checker) Forget(domainName string) {
|
|||||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||||
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
||||||
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
||||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
|
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
hub := EmailDomain(q.DMARCReportEmail)
|
||||||
|
if hub != "" && !strings.EqualFold(hub, q.Name) {
|
||||||
|
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
|
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
txt["_dmarc.example.com"] = c.record
|
txt["_dmarc.example.com"] = c.record
|
||||||
}
|
}
|
||||||
f := &fakeResolver{txt: txt}
|
f := &fakeResolver{txt: txt}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if got.Status != c.want {
|
if got.Status != c.want {
|
||||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
|
|
||||||
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
||||||
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if !strings.Contains(got.Detail, "monitoring only") {
|
if !strings.Contains(got.Detail, "monitoring only") {
|
||||||
t.Errorf("p=none is not explained: %s", got.Detail)
|
t.Errorf("p=none is not explained: %s", got.Detail)
|
||||||
}
|
}
|
||||||
@@ -357,3 +357,20 @@ func TestForgetDropsTheCachedDomain(t *testing.T) {
|
|||||||
t.Error("Forget did not drop the cached result")
|
t.Error("Forget did not drop the cached result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportAuth(t *testing.T) {
|
||||||
|
f := &fakeResolver{txt: map[string][]string{"_report._dmarc.hub.example": {"v=DMARC1;"}}}
|
||||||
|
got := newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusOK {
|
||||||
|
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = &fakeResolver{}
|
||||||
|
got = newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusWarn {
|
||||||
|
t.Fatalf("missing = %q, want warn", got.Status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Detail, ReportAuthExample()) {
|
||||||
|
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dnscheck
|
package dnscheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
// helper: it is published at the domain itself.)
|
// helper: it is published at the domain itself.)
|
||||||
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
||||||
|
|
||||||
|
// ReportAuthRecordName is where a report-receiving domain authorises external
|
||||||
|
// DMARC aggregate destinations (RFC 7489 §7.1).
|
||||||
|
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
|
||||||
|
|
||||||
|
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
|
||||||
|
func ReportAuthExample() string { return "v=DMARC1;" }
|
||||||
|
|
||||||
// SPFExample is the SPF record this server expects for a sending domain: the
|
// SPFExample is the SPF record this server expects for a sending domain: the
|
||||||
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
||||||
// is authorised. When the server's own addresses are not known (its hostname
|
// is authorised. When the server's own addresses are not known (its hostname
|
||||||
@@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string {
|
|||||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DMARCExample is the least a domain should publish: monitoring only, with an
|
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
|
||||||
// address the aggregate reports go to. p=none is deliberate — it changes
|
// override wins, then the administrator profile, then policy-only (empty).
|
||||||
// nothing about delivery, so it is safe to publish before the reports have
|
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
|
||||||
// shown that DKIM and SPF pass everywhere.
|
if domainRua.Valid {
|
||||||
func DMARCExample(domainName string) string {
|
return domainRua.String
|
||||||
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
|
}
|
||||||
|
return profileEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||||
|
func EmailDomain(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at < 0 || at == len(addr)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCExample is the DMARC TXT record this server suggests for a sending
|
||||||
|
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
|
||||||
|
// included only when reportEmail is set; SelfPost is send-only and most
|
||||||
|
// operators have no inbox on the sending domain itself.
|
||||||
|
func DMARCExample(reportEmail string) string {
|
||||||
|
base := "v=DMARC1; p=none"
|
||||||
|
if reportEmail == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "; rua=mailto:" + reportEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalReportAuth reports whether the hub domain must publish a
|
||||||
|
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
|
||||||
|
// sendingDomain.
|
||||||
|
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
|
||||||
|
hub := EmailDomain(reportEmail)
|
||||||
|
if hub == "" || strings.EqualFold(hub, sendingDomain) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return ReportAuthRecordName(hub), ReportAuthExample(), true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dnscheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,8 +19,6 @@ func TestSPFExample(t *testing.T) {
|
|||||||
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
||||||
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
||||||
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
||||||
// The hostname does not resolve, so there is no address to name; an "a:"
|
|
||||||
// mechanism still gives the operator a publishable record.
|
|
||||||
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
||||||
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
||||||
}
|
}
|
||||||
@@ -32,6 +31,41 @@ func TestSPFExample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDMARCExample(t *testing.T) {
|
||||||
|
if got := DMARCExample(""); got != "v=DMARC1; p=none" {
|
||||||
|
t.Errorf("empty = %q", got)
|
||||||
|
}
|
||||||
|
want := "v=DMARC1; p=none; rua=mailto:reports@hub.example"
|
||||||
|
if got := DMARCExample("reports@hub.example"); got != want {
|
||||||
|
t.Errorf("with rua = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDMARCRua(t *testing.T) {
|
||||||
|
inherit := sql.NullString{}
|
||||||
|
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
|
||||||
|
t.Errorf("inherit profile = %q", got)
|
||||||
|
}
|
||||||
|
none := sql.NullString{Valid: true}
|
||||||
|
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
|
||||||
|
t.Errorf("explicit none = %q", got)
|
||||||
|
}
|
||||||
|
custom := sql.NullString{Valid: true, String: "x@y.com"}
|
||||||
|
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
|
||||||
|
t.Errorf("custom = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalReportAuth(t *testing.T) {
|
||||||
|
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||||
|
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
||||||
|
t.Fatalf("external = (%q, %q, %v)", name, value, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := ExternalReportAuth("shop.com", "dmarc@shop.com"); ok {
|
||||||
|
t.Fatal("same domain should not need external auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The record the panel shows and the one a failed check suggests must be the
|
// The record the panel shows and the one a failed check suggests must be the
|
||||||
// same string, or the operator is told two different things on one page.
|
// same string, or the operator is told two different things on one page.
|
||||||
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||||
@@ -50,11 +84,11 @@ func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
|||||||
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
dmarc := c.checkDMARC(context.Background(), "example.com")
|
dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if dmarc.Status != health.StatusWarn {
|
if dmarc.Status != health.StatusWarn {
|
||||||
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
||||||
}
|
}
|
||||||
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
|
if want := DMARCExample(""); !strings.Contains(dmarc.Detail, want) {
|
||||||
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
|||||||
|
|
||||||
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
||||||
// required for delivery, so its absence is advice (warn), not a fault.
|
// required for delivery, so its absence is advice (warn), not a fault.
|
||||||
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
|
||||||
name := DMARCRecordName(domainName)
|
name := DMARCRecordName(q.Name)
|
||||||
txt, found, err := c.lookupTXT(ctx, name)
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return lookupFailed("the DMARC record", err)
|
return lookupFailed("the DMARC record", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
example := DMARCExample(q.DMARCReportEmail)
|
||||||
|
|
||||||
var records []string
|
var records []string
|
||||||
for _, rec := range txt {
|
for _, rec := range txt {
|
||||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||||
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found || len(records) == 0 {
|
if !found || len(records) == 0 {
|
||||||
return Result{
|
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
|
||||||
Status: health.StatusWarn,
|
if q.DMARCReportEmail == "" {
|
||||||
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
|
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
|
||||||
}
|
}
|
||||||
|
return Result{Status: health.StatusWarn, Detail: detail}
|
||||||
}
|
}
|
||||||
if len(records) > 1 {
|
if len(records) > 1 {
|
||||||
return Result{
|
return Result{
|
||||||
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
|
||||||
|
// aggregate-report destinations. Missing authorisation does not affect outbound
|
||||||
|
// delivery, only whether reports reach the rua= mailbox.
|
||||||
|
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
name := ReportAuthRecordName(hubDomain)
|
||||||
|
expected := ReportAuthExample()
|
||||||
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return lookupFailed("the DMARC report-authorisation record", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []string
|
||||||
|
for _, rec := range txt {
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || len(records) == 0 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusWarn,
|
||||||
|
Detail: fmt.Sprintf("No report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(records) > 1 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusOK,
|
||||||
|
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
|
||||||
|
// reports. It is used on the settings page for the administrator profile.
|
||||||
|
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
if hubDomain == "" {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return c.checkReportAuth(ctx, hubDomain)
|
||||||
|
}
|
||||||
|
|
||||||
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
||||||
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
||||||
// line breaks into the base64, none of which are part of the key.
|
// line breaks into the base64, none of which are part of the key.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
@@ -23,6 +24,7 @@ type DomainExport struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
DKIMSelector string `json:"dkim_selector"`
|
DKIMSelector string `json:"dkim_selector"`
|
||||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||||
|
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
|||||||
DKIMPrivateKey: string(pem),
|
DKIMPrivateKey: string(pem),
|
||||||
Applications: make([]AppExport, 0, len(apps)),
|
Applications: make([]AppExport, 0, len(apps)),
|
||||||
}
|
}
|
||||||
|
if d.DMARCRua.Valid {
|
||||||
|
s := d.DMARCRua.String
|
||||||
|
exp.DMARCRua = &s
|
||||||
|
}
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
password, err := s.apps.Secret(a.Login)
|
password, err := s.apps.Secret(a.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,6 +116,14 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
return store.Domain{}, err
|
return store.Domain{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exp.DMARCRua != nil {
|
||||||
|
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, err
|
||||||
|
}
|
||||||
|
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
||||||
|
}
|
||||||
|
|
||||||
for _, a := range exp.Applications {
|
for _, a := range exp.Applications {
|
||||||
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,6 +73,9 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||||
t.Fatalf("add alerts: %v", err)
|
t.Fatalf("add alerts: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
|
||||||
|
t.Fatalf("set dmarc rua: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
exp, err := src.Export(d.ID)
|
exp, err := src.Export(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +111,12 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||||
t.Errorf("imported domain = %+v", got)
|
t.Errorf("imported domain = %+v", got)
|
||||||
}
|
}
|
||||||
|
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
|
||||||
|
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||||
|
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
||||||
|
}
|
||||||
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
||||||
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Package legal embeds SelfPost's AGPL-3.0 licence text so the panel can
|
||||||
|
// serve it at /license (Appropriate Legal Notices) without depending on
|
||||||
|
// GitHub or a file on disk at runtime.
|
||||||
|
//
|
||||||
|
// The copy in this directory must stay identical to the repository-root
|
||||||
|
// LICENSE; license_test.go enforces that.
|
||||||
|
package legal
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed LICENSE
|
||||||
|
var License []byte
|
||||||
|
|
||||||
|
// SourceURL is where Corresponding Source for the published upstream
|
||||||
|
// releases lives. Operators who ship a modified version must point their
|
||||||
|
// users at their own sources instead (NOTICE; AGPL-3.0 §13).
|
||||||
|
const SourceURL = "https://github.com/mixeme/selfpost"
|
||||||
|
|
||||||
|
// CopyrightLine is the short copyright notice shown in the panel footer.
|
||||||
|
const CopyrightLine = "Copyright © 2026 Mikhail Yenuchenko"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package legal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
|
||||||
|
root, err := os.ReadFile(filepath.Join("..", "..", "LICENSE"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read root LICENSE: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(root, License) {
|
||||||
|
t.Fatal("internal/legal/LICENSE differs from the repository-root LICENSE; copy the root file over")
|
||||||
|
}
|
||||||
|
if len(License) == 0 {
|
||||||
|
t.Fatal("embedded LICENSE is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +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
|
|
||||||
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 string) error {
|
|
||||||
res, err := s.db.Exec(
|
|
||||||
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
|
||||||
username, passwordHash,
|
|
||||||
)
|
|
||||||
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, created_at FROM admin WHERE id = 1").
|
|
||||||
Scan(&a.Username, &a.PasswordHash, &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,45 +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"); err != nil {
|
|
||||||
t.Fatalf("UpdateAdmin: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := st.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAdmin: %v", err)
|
|
||||||
}
|
|
||||||
if a.Username != "operator" || a.PasswordHash != "hash-two" {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ type Domain struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
DKIMSelector string
|
DKIMSelector string
|
||||||
|
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
AppCount int
|
AppCount int
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
|||||||
// ordered by name.
|
// ordered by name.
|
||||||
func (s *Store) ListDomains() ([]Domain, error) {
|
func (s *Store) ListDomains() ([]Domain, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.created_at,
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
ORDER BY d.name`)
|
ORDER BY d.name`)
|
||||||
@@ -78,7 +79,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
|||||||
// ErrDomainNotFound.
|
// ErrDomainNotFound.
|
||||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.created_at,
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
WHERE d.id = ?`, id)
|
WHERE d.id = ?`, id)
|
||||||
@@ -120,7 +121,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
|||||||
d Domain
|
d Domain
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
|
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||||
return Domain{}, err
|
return Domain{}, err
|
||||||
}
|
}
|
||||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
@@ -137,3 +138,21 @@ func isUniqueViolation(err error) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
||||||
|
// NULL means inherit the administrator profile; Valid with an empty string means
|
||||||
|
// policy-only with no aggregate reports for this domain.
|
||||||
|
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||||
|
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update domain dmarc rua rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrDomainNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainDMARCRua(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d, err := st.AddDomain("example.com", "sel")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddDomain: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.GetDomain(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDomain: %v", err)
|
||||||
|
}
|
||||||
|
if got.DMARCRua.Valid {
|
||||||
|
t.Fatal("new domain should inherit profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
|
||||||
|
}
|
||||||
|
got, err = st.GetDomain(d.ID)
|
||||||
|
if err != nil || got.DMARCRua.String != "reports@hub.com" {
|
||||||
|
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = st.GetDomain(d.ID)
|
||||||
|
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
||||||
|
t.Fatalf("none = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = st.GetDomain(d.ID)
|
||||||
|
if got.DMARCRua.Valid {
|
||||||
|
t.Fatalf("inherit = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
|
||||||
|
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
|
||||||
|
|
||||||
|
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Package auth implements the panel's login sessions, one-time setup flow,
|
||||||
|
// and authentication middleware.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds auth-specific panel configuration.
|
||||||
|
type Config struct {
|
||||||
|
CookieSecure bool
|
||||||
|
Hostname string
|
||||||
|
SessionIdleDays int
|
||||||
|
TrustedProxyCIDRs []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module handles login, logout, setup, and session middleware.
|
||||||
|
type Module struct {
|
||||||
|
store *store.Store
|
||||||
|
cfg Config
|
||||||
|
view *view.Engine
|
||||||
|
sessions *sessionStore
|
||||||
|
setup *setupManager
|
||||||
|
|
||||||
|
loginLimiter *rateLimiter
|
||||||
|
setupLimiter *rateLimiter
|
||||||
|
|
||||||
|
trustedProxies []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the auth module. setupTokenPath is where the current setup token
|
||||||
|
// is mirrored on disk (security.md).
|
||||||
|
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
|
||||||
|
idleDays := cfg.SessionIdleDays
|
||||||
|
if idleDays <= 0 {
|
||||||
|
idleDays = 7
|
||||||
|
}
|
||||||
|
m := &Module{
|
||||||
|
store: st,
|
||||||
|
cfg: cfg,
|
||||||
|
view: v,
|
||||||
|
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||||
|
setupLimiter: newRateLimiter(10, time.Minute),
|
||||||
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||||
|
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||||
|
}
|
||||||
|
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap runs once at startup. If setup is not complete it mints and
|
||||||
|
// announces the first setup token (security.md).
|
||||||
|
func (m *Module) Bootstrap() error {
|
||||||
|
return m.setup.bootstrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowLoginAttempt reports whether a login or account-password change attempt
|
||||||
|
// from r is within the rate limit (security.md).
|
||||||
|
func (m *Module) AllowLoginAttempt(r *http.Request) bool {
|
||||||
|
return m.loginLimiter.Allow(clientIP(r, m.trustedProxies))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionToken returns the session token the request carries, if exactly one
|
||||||
|
// cookie of that name is present.
|
||||||
|
func (m *Module) SessionToken(r *http.Request) (string, bool) {
|
||||||
|
return m.sessionToken(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameSession updates the username carried by a session.
|
||||||
|
func (m *Module) RenameSession(token, username string) {
|
||||||
|
m.sessions.Rename(token, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroyOtherSessions invalidates every session except keep.
|
||||||
|
func (m *Module) DestroyOtherSessions(keep string) {
|
||||||
|
m.sessions.DestroyOthers(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
||||||
|
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
||||||
|
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||||
|
// that is the address the trusted proxy itself appended, so a client can't
|
||||||
|
// forge it by sending its own XFF header.
|
||||||
|
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trustedProxies) > 0 {
|
||||||
|
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
||||||
|
for _, n := range nets {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,72 +1,95 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The __Host- prefix is only valid on a Secure cookie: getting this condition
|
func newTestSessionStore(t *testing.T) *sessionStore {
|
||||||
// backwards would make the development instance fail to log in at all, and
|
t.Helper()
|
||||||
// silently — the browser discards the Set-Cookie and the panel just shows the
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
// login form again.
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return newSessionStore(st, 7*24*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustView(t *testing.T) *view.Engine {
|
||||||
|
t.Helper()
|
||||||
|
v, err := view.New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModule(t *testing.T, cookieSecure bool) *Module {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
||||||
secure := &Server{cfg: Config{CookieSecure: true}}
|
secure := testModule(t, true)
|
||||||
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
||||||
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
||||||
}
|
}
|
||||||
plain := &Server{cfg: Config{CookieSecure: false}}
|
plain := testModule(t, false)
|
||||||
if got := plain.sessionCookie(); got != "selfpost_session" {
|
if got := plain.sessionCookie(); got != "selfpost_session" {
|
||||||
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A neighbouring host on the same registrable domain can set a cookie by the
|
|
||||||
// same name; the browser then sends both, oldest first. Picking one at random
|
|
||||||
// would leave the administrator in a login loop with no explanation, so the
|
|
||||||
// request counts as signed out instead.
|
|
||||||
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: false}}
|
m := testModule(t, false)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
||||||
|
|
||||||
if token, ok := s.sessionToken(r); ok {
|
if token, ok := m.sessionToken(r); ok {
|
||||||
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}}
|
m := testModule(t, true)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
||||||
|
|
||||||
token, ok := s.sessionToken(r)
|
token, ok := m.sessionToken(r)
|
||||||
if !ok || token != "the-real-session" {
|
if !ok || token != "the-real-session" {
|
||||||
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cookie under the other deployment's name is not this deployment's session:
|
|
||||||
// after an upgrade the pre-14 cookie must not be honoured as if it were the
|
|
||||||
// prefixed one.
|
|
||||||
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}}
|
m := testModule(t, true)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
||||||
|
|
||||||
if _, ok := s.sessionToken(r); ok {
|
if _, ok := m.sessionToken(r); ok {
|
||||||
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
|
m := testModule(t, false)
|
||||||
token := s.sessions.Create("admin")
|
token := m.sessions.Create("admin")
|
||||||
|
|
||||||
reached := false
|
reached := false
|
||||||
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||||
@@ -82,19 +105,17 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signing out has to expire the cookie under both names, or the cookie left
|
|
||||||
// over from a pre-__Host- build stays in the browser for the rest of its life.
|
|
||||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
|
m := testModule(t, true)
|
||||||
token := s.sessions.Create("admin")
|
token := m.sessions.Create("admin")
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||||
r.Host = "panel.example.com"
|
r.Host = "panel.example.com"
|
||||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.handleLogout(rec, r)
|
m.HandleLogout(rec, r)
|
||||||
|
|
||||||
if _, ok := s.sessions.Lookup(token); ok {
|
if _, ok := m.sessions.Lookup(token); ok {
|
||||||
t.Error("the session survived sign-out")
|
t.Error("the session survived sign-out")
|
||||||
}
|
}
|
||||||
set := rec.Header().Values("Set-Cookie")
|
set := rec.Header().Values("Set-Cookie")
|
||||||
@@ -110,3 +131,59 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionRename(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
s.Rename(token, "operator")
|
||||||
|
|
||||||
|
name, ok := s.Lookup(token)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("session lost after rename")
|
||||||
|
}
|
||||||
|
if name != "operator" {
|
||||||
|
t.Fatalf("session username = %q, want %q", name, "operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDestroyOthers(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
keep := s.Create("admin")
|
||||||
|
other := s.Create("admin")
|
||||||
|
|
||||||
|
s.DestroyOthers(keep)
|
||||||
|
|
||||||
|
if _, ok := s.Lookup(keep); !ok {
|
||||||
|
t.Fatal("current session was destroyed")
|
||||||
|
}
|
||||||
|
if _, ok := s.Lookup(other); ok {
|
||||||
|
t.Fatal("other session survived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
s.idle = -time.Minute
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
if _, ok := s.Lookup(token); ok {
|
||||||
|
t.Fatal("expired session was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTouchThrottled(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
if s.Touch(token) {
|
||||||
|
t.Fatal("touch renewed a session created moments ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
||||||
|
t.Fatalf("renew session: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Touch(token) {
|
||||||
|
t.Fatal("touch did not renew a session past the throttle window")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieBase = "selfpost_session"
|
||||||
|
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Module) sessionCookie() string {
|
||||||
|
if m.cfg.CookieSecure {
|
||||||
|
return sessionCookiePrefixed
|
||||||
|
}
|
||||||
|
return sessionCookieBase
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
||||||
|
name := m.sessionCookie()
|
||||||
|
var token string
|
||||||
|
var n int
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
n++
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case 0:
|
||||||
|
return "", false
|
||||||
|
case 1:
|
||||||
|
return token, true
|
||||||
|
default:
|
||||||
|
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
||||||
|
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||||
|
r.Method, r.URL.Path, n, name)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
||||||
|
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin serves the login form (GET) and authenticates (POST).
|
||||||
|
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
exists, err := m.store.UserExists()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
m.view.Render(w, http.StatusOK, "login", map[string]any{
|
||||||
|
"Title": "SelfPost — Sign in",
|
||||||
|
"Active": "login",
|
||||||
|
"SetupHint": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
m.renderLogin(w, http.StatusOK, "")
|
||||||
|
case http.MethodPost:
|
||||||
|
m.submitLogin(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
||||||
|
m.view.Render(w, status, "login", map[string]any{
|
||||||
|
"Title": "SelfPost — Sign in",
|
||||||
|
"Active": "login",
|
||||||
|
"Error": formErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||||
|
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
|
||||||
|
user, err := m.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, store.ErrUserNotFound) {
|
||||||
|
logf("panel: login: get user failed: %v", err)
|
||||||
|
}
|
||||||
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := m.sessions.Create(user.Username)
|
||||||
|
m.setSessionCookie(w, token)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: m.sessionCookie(),
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: m.sessions.MaxAge(),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: m.cfg.CookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout destroys the session and clears the cookie.
|
||||||
|
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := m.sessionCookie()
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
m.sessions.Destroy(c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.clearSessionCookies(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSetup serves the one-time administrator creation flow at
|
||||||
|
// /setup/<token> (security.md).
|
||||||
|
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||||
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
||||||
|
if token == "" || strings.Contains(token, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.setup.validate(token) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
m.renderSetupForm(w, http.StatusOK, token, "")
|
||||||
|
case http.MethodPost:
|
||||||
|
m.submitSetup(w, r, token)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
||||||
|
m.view.Render(w, status, "setup", map[string]any{
|
||||||
|
"Title": "SelfPost — Create administrator",
|
||||||
|
"Active": "setup",
|
||||||
|
"Token": token,
|
||||||
|
"Error": formErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
confirm := r.PostFormValue("password_confirm")
|
||||||
|
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if password != confirm {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: setup: hashing password failed: %v", err)
|
||||||
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
|
||||||
|
if exists, _ := m.store.UserExists(); exists {
|
||||||
|
m.setup.complete()
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: setup: create user failed: %v", err)
|
||||||
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setup.complete()
|
||||||
|
logf("panel: administrator %q created; setup link is now disabled", username)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAuth wraps a handler so only requests with a valid session cookie
|
||||||
|
// reach it; everyone else is redirected to the login page. The authenticated
|
||||||
|
// principal is stashed in the request context for downstream handlers.
|
||||||
|
func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := m.sessionToken(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, ok := m.sessions.Lookup(token)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSessionActivity(r) && m.sessions.Touch(token) {
|
||||||
|
m.setSessionCookie(w, token)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSessionActivity(r *http.Request) bool {
|
||||||
|
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentUser returns the authenticated username from the request context.
|
||||||
|
func CurrentUser(r *http.Request) string {
|
||||||
|
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||||
// setup and login routes (security.md). Keys are client IPs. It is not a
|
// setup and login routes (security.md). Keys are client IPs.
|
||||||
// precise sliding window — a coarse backstop against brute-force and log noise
|
|
||||||
// is all these routes need.
|
|
||||||
type rateLimiter struct {
|
type rateLimiter struct {
|
||||||
max int
|
max int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
@@ -30,8 +28,6 @@ func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow records an attempt for key and reports whether it is within the limit.
|
|
||||||
// The current window is reset lazily once it elapses.
|
|
||||||
func (r *rateLimiter) Allow(key string) bool {
|
func (r *rateLimiter) Allow(key string) bool {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep drops expired buckets so the map cannot grow without bound. Called
|
|
||||||
// under the lock while a window is being reset, which is often enough given the
|
|
||||||
// low request volume of these routes.
|
|
||||||
func (r *rateLimiter) sweep(now time.Time) {
|
func (r *rateLimiter) sweep(now time.Time) {
|
||||||
for k, b := range r.buckets {
|
for k, b := range r.buckets {
|
||||||
if now.After(b.windowEnds) {
|
if now.After(b.windowEnds) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -22,10 +22,7 @@ const renewThreshold = time.Hour
|
|||||||
// cookie.
|
// cookie.
|
||||||
type sessionStore struct {
|
type sessionStore struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
|
idle time.Duration
|
||||||
// no absolute cap: an administrator who keeps coming back stays signed in
|
|
||||||
// indefinitely, deliberately.
|
|
||||||
idle time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||||
@@ -51,10 +48,6 @@ func (s *sessionStore) Create(username string) string {
|
|||||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||||
logf("panel: session: create failed: %v", err)
|
logf("panel: session: create failed: %v", err)
|
||||||
}
|
}
|
||||||
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
|
|
||||||
// in the table forever. Piggybacking on Create (the one write every login
|
|
||||||
// already pays for) avoids a dedicated background sweep for what is, on a
|
|
||||||
// single-admin panel, a handful of rows at most.
|
|
||||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||||
logf("panel: session: prune expired failed: %v", err)
|
logf("panel: session: prune expired failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,10 +79,7 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Touch extends a session's sliding expiry if it has been at least
|
// Touch extends a session's sliding expiry if it has been at least
|
||||||
// renewThreshold since the last extension, and reports whether it did so —
|
// renewThreshold since the last extension, and reports whether it did so.
|
||||||
// the caller uses that to decide whether the response needs a fresh
|
|
||||||
// Set-Cookie. It assumes the caller has just confirmed the session is valid
|
|
||||||
// (e.g. via Lookup); it does nothing for a token that no longer exists.
|
|
||||||
func (s *sessionStore) Touch(token string) bool {
|
func (s *sessionStore) Touch(token string) bool {
|
||||||
hash := hashToken(token)
|
hash := hashToken(token)
|
||||||
row, found, err := s.store.LookupSession(hash)
|
row, found, err := s.store.LookupSession(hash)
|
||||||
@@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool {
|
|||||||
if !found {
|
if !found {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// expiresAt = lastRenewal + idle, so this recovers when the session was
|
|
||||||
// last extended without a separate column.
|
|
||||||
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(lastRenewal) < renewThreshold {
|
if now.Sub(lastRenewal) < renewThreshold {
|
||||||
@@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename updates the username carried by a session, keeping its expiry. It is
|
// Rename updates the username carried by a session, keeping its expiry.
|
||||||
// used when the administrator renames their own account so the current
|
|
||||||
// session keeps working under the new name.
|
|
||||||
func (s *sessionStore) Rename(token, username string) {
|
func (s *sessionStore) Rename(token, username string) {
|
||||||
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
||||||
logf("panel: session: rename failed: %v", err)
|
logf("panel: session: rename failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DestroyOthers invalidates every session except keep. It is called when the
|
// DestroyOthers invalidates every session except keep.
|
||||||
// administrator changes their password: a stolen cookie issued under the old
|
|
||||||
// password must stop working, while the admin performing the change stays
|
|
||||||
// signed in.
|
|
||||||
func (s *sessionStore) DestroyOthers(keep string) {
|
func (s *sessionStore) DestroyOthers(keep string) {
|
||||||
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
||||||
logf("panel: session: destroy others failed: %v", err)
|
logf("panel: session: destroy others failed: %v", err)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -32,10 +32,8 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
|
|||||||
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrap runs once at startup. If setup is already complete it clears any
|
|
||||||
// stale token file; otherwise it mints and announces the first token.
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -49,13 +47,10 @@ func (m *setupManager) bootstrap() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeToken returns the current valid setup token, regenerating and
|
|
||||||
// re-announcing it if none exists or it has expired. It returns ("", false)
|
|
||||||
// once setup is complete — callers must treat that as "route gone" (404).
|
|
||||||
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 {
|
||||||
@@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) {
|
|||||||
return m.token, true
|
return m.token, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate reports whether provided matches the active token, using a
|
|
||||||
// constant-time comparison to avoid leaking a correct prefix via timing
|
|
||||||
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
|
|
||||||
// attempts must not let an attacker DoS a legitimate setup (security.md).
|
|
||||||
func (m *setupManager) validate(provided string) bool {
|
func (m *setupManager) validate(provided string) bool {
|
||||||
token, ok := m.activeToken()
|
token, ok := m.activeToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool {
|
|||||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// complete marks setup as finished: the admin row now exists, so drop the
|
|
||||||
// in-memory token and remove the on-disk copy.
|
|
||||||
func (m *setupManager) complete() {
|
func (m *setupManager) complete() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.token = ""
|
m.token = ""
|
||||||
@@ -91,16 +80,12 @@ func (m *setupManager) complete() {
|
|||||||
m.clearTokenFile()
|
m.clearTokenFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
|
|
||||||
// Caller holds m.mu.
|
|
||||||
func (m *setupManager) regenerateLocked() {
|
func (m *setupManager) regenerateLocked() {
|
||||||
m.token = randomToken(16) // 128 bits of entropy (security.md)
|
m.token = randomToken(16)
|
||||||
m.expiresAt = time.Now().Add(setupTokenTTL)
|
m.expiresAt = time.Now().Add(setupTokenTTL)
|
||||||
m.announce(m.token)
|
m.announce(m.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// announce prints the setup link to the container log and writes it to the
|
|
||||||
// token file so it can be read either way (security.md).
|
|
||||||
func (m *setupManager) announce(token string) {
|
func (m *setupManager) announce(token string) {
|
||||||
url := m.setupURL(token)
|
url := m.setupURL(token)
|
||||||
logf("panel: ==================================================================")
|
logf("panel: ==================================================================")
|
||||||
@@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) {
|
|||||||
if m.tokenPath == "" {
|
if m.tokenPath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 0600: the token is a bearer secret for creating the admin.
|
|
||||||
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
||||||
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Package handlers implements the panel's authenticated HTTP handlers.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds handler-specific panel configuration.
|
||||||
|
type Config struct {
|
||||||
|
Hostname string
|
||||||
|
SubmissionEnabled bool
|
||||||
|
MailLogPath string
|
||||||
|
DataDir string
|
||||||
|
DBPath string
|
||||||
|
Version string
|
||||||
|
TLSCertFile string
|
||||||
|
OpenDKIMSocket string
|
||||||
|
JournalSocket string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers holds dependencies for authenticated panel routes.
|
||||||
|
type Handlers struct {
|
||||||
|
store *store.Store
|
||||||
|
domains *domain.Service
|
||||||
|
apps *app.Service
|
||||||
|
cfg Config
|
||||||
|
view *view.Engine
|
||||||
|
dns *dnscheck.Checker
|
||||||
|
machine *health.MachineSampler
|
||||||
|
auth *auth.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds authenticated panel handlers.
|
||||||
|
func New(
|
||||||
|
st *store.Store,
|
||||||
|
domains *domain.Service,
|
||||||
|
apps *app.Service,
|
||||||
|
cfg Config,
|
||||||
|
v *view.Engine,
|
||||||
|
dns *dnscheck.Checker,
|
||||||
|
machine *health.MachineSampler,
|
||||||
|
a *auth.Module,
|
||||||
|
) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
store: st,
|
||||||
|
domains: domains,
|
||||||
|
apps: apps,
|
||||||
|
cfg: cfg,
|
||||||
|
view: v,
|
||||||
|
dns: dns,
|
||||||
|
machine: machine,
|
||||||
|
auth: a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAccount serves the signed-in user's account settings.
|
||||||
|
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.store.GetUser(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: account: get user failed: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
|
||||||
|
case http.MethodPost:
|
||||||
|
h.submitAccount(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||||
|
var reportAuth dnscheck.Result
|
||||||
|
if showDMARC && formDMARCEmail != "" {
|
||||||
|
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost — settings"
|
||||||
|
data["Active"] = "account"
|
||||||
|
data["FormUsername"] = formUsername
|
||||||
|
data["FormDMARCEmail"] = formDMARCEmail
|
||||||
|
data["ShowDMARC"] = showDMARC
|
||||||
|
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
|
||||||
|
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
|
||||||
|
data["ReportAuthDNS"] = reportAuth
|
||||||
|
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||||
|
data["Error"] = formErr
|
||||||
|
data["Flash"] = accountFlash(r)
|
||||||
|
h.view.Render(w, status, "account", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountFlash(r *http.Request) string {
|
||||||
|
switch r.URL.Query().Get("updated") {
|
||||||
|
case "username":
|
||||||
|
return "Username changed."
|
||||||
|
case "password":
|
||||||
|
return "Password changed. Any other signed-in sessions were signed out."
|
||||||
|
case "both":
|
||||||
|
return "Username and password changed. Any other signed-in sessions were signed out."
|
||||||
|
case "email":
|
||||||
|
return "DMARC report address updated."
|
||||||
|
case "username-email":
|
||||||
|
return "Username and DMARC report address updated."
|
||||||
|
case "password-email":
|
||||||
|
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
|
||||||
|
case "all":
|
||||||
|
return "Settings updated. Any other signed-in sessions were signed out."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.auth.AllowLoginAttempt(r) {
|
||||||
|
p, _ := h.principal(r)
|
||||||
|
h.renderAccount(w, r, http.StatusTooManyRequests,
|
||||||
|
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
current := r.PostFormValue("current_password")
|
||||||
|
password := r.PostFormValue("new_password")
|
||||||
|
confirm := r.PostFormValue("new_password_confirm")
|
||||||
|
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
dmarcEmail = user.DMARCReportEmail
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renaming := username != user.Username
|
||||||
|
if renaming {
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsGlobal() {
|
||||||
|
if err := validate.Email(dmarcEmail); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
|
||||||
|
|
||||||
|
repassword := password != "" || confirm != ""
|
||||||
|
if repassword {
|
||||||
|
if password != confirm {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !renaming && !repassword && !emailChanging {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest,
|
||||||
|
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := user.PasswordHash
|
||||||
|
if repassword {
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: account: hashing password failed: %v", err)
|
||||||
|
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||||
|
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash = string(newHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
|
||||||
|
logf("panel: account: update user failed: %v", err)
|
||||||
|
msg := "Could not save the changes. Please check the logs and try again."
|
||||||
|
if errors.Is(err, store.ErrUserNotFound) {
|
||||||
|
msg = "There is no user account to update."
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if token, ok := h.auth.SessionToken(r); ok {
|
||||||
|
if renaming {
|
||||||
|
h.auth.RenameSession(token, username)
|
||||||
|
}
|
||||||
|
if repassword {
|
||||||
|
h.auth.DestroyOtherSessions(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||||
|
switch {
|
||||||
|
case renamed && repassword && emailChanged:
|
||||||
|
return "all"
|
||||||
|
case renamed && emailChanged:
|
||||||
|
return "username-email"
|
||||||
|
case repassword && emailChanged:
|
||||||
|
return "password-email"
|
||||||
|
case renamed && repassword:
|
||||||
|
return "both"
|
||||||
|
case renamed:
|
||||||
|
return "username"
|
||||||
|
case repassword:
|
||||||
|
return "password"
|
||||||
|
default:
|
||||||
|
return "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,6 +10,7 @@ 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/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newCred carries a freshly generated login/password to the template so it can
|
// newCred carries a freshly generated login/password to the template so it can
|
||||||
@@ -48,28 +49,28 @@ type appRateLimitView struct {
|
|||||||
WindowVal string // window seconds, defaulted when unset
|
WindowVal string // window seconds, defaulted when unset
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
|
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
|
||||||
// and its applications with the controls to add, edit, delete and re-issue
|
// and its applications with the controls to add, edit, delete and re-issue
|
||||||
// credentials (product.md).
|
// credentials (product.md).
|
||||||
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDomainDetail renders the domain page. view supplies request-specific
|
// renderDomainDetail renders the domain page. view supplies request-specific
|
||||||
// extras (form error/values, a one-time credential); everything else is loaded
|
// extras (form error/values, a one-time credential); everything else is loaded
|
||||||
// fresh from the stores so the page always reflects committed state.
|
// fresh from the stores so the page always reflects committed state.
|
||||||
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
||||||
record, err := s.domains.DKIMRecord(d)
|
record, err := h.domains.DKIMRecord(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apps, err := s.apps.List(d.ID)
|
apps, err := h.apps.List(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: list applications: %v", d.ID, err)
|
logf("panel: domain %d: list applications: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -77,7 +78,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
}
|
}
|
||||||
appViews := make([]appRateLimitView, 0, len(apps))
|
appViews := make([]appRateLimitView, 0, len(apps))
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
rl, ok, err := s.apps.RateLimit(a.ID)
|
rl, ok, err := h.apps.RateLimit(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: application %d: rate limit: %v", a.ID, err)
|
logf("panel: application %d: rate limit: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -92,7 +93,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
|
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -102,45 +103,72 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
// 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.
|
||||||
dns, srv := s.domainDNS(d, record, false)
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||||
|
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||||
|
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||||
|
dmarcMode := "inherit"
|
||||||
|
dmarcCustom := ""
|
||||||
|
if d.DMARCRua.Valid {
|
||||||
|
if d.DMARCRua.String == "" {
|
||||||
|
dmarcMode = "none"
|
||||||
|
} else {
|
||||||
|
dmarcMode = "custom"
|
||||||
|
dmarcCustom = d.DMARCRua.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dmarcSource := "policy"
|
||||||
|
switch {
|
||||||
|
case dmarcMode == "custom":
|
||||||
|
dmarcSource = "custom"
|
||||||
|
case dmarcMode == "none":
|
||||||
|
dmarcSource = "none"
|
||||||
|
case profileEmail != "":
|
||||||
|
dmarcSource = "settings"
|
||||||
|
}
|
||||||
|
|
||||||
s.render(w, status, "domain_detail", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — " + d.Name,
|
data["Title"] = "SelfPost — " + d.Name
|
||||||
"User": 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(s.cfg.Hostname, srv.IPs),
|
data["DMARCRuaMode"] = dmarcMode
|
||||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
data["DMARCRuaCustom"] = dmarcCustom
|
||||||
"DMARCExample": dnscheck.DMARCExample(d.Name),
|
data["ReportAuthName"] = reportAuthName
|
||||||
// Client connection settings (the same for every domain on this
|
data["ReportAuthValue"] = reportAuthValue
|
||||||
// instance): the hostname clients connect to, and whether the optional
|
data["NeedsReportAuth"] = needsReportAuth
|
||||||
// submission listener is enabled in this deployment.
|
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||||
"Hostname": s.cfg.Hostname,
|
data["Hostname"] = h.cfg.Hostname
|
||||||
"SubmissionEnabled": s.cfg.SubmissionEnabled,
|
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||||
"Apps": appViews,
|
data["Apps"] = appViews
|
||||||
"Error": view.FormErr,
|
data["Error"] = view.FormErr
|
||||||
"FormLogin": view.FormLogin,
|
data["FormLogin"] = view.FormLogin
|
||||||
"FormMode": view.FormMode,
|
data["FormMode"] = view.FormMode
|
||||||
"FormAddrs": view.FormAddrs,
|
data["FormAddrs"] = view.FormAddrs
|
||||||
"NewCred": view.NewCred,
|
data["NewCred"] = view.NewCred
|
||||||
"Flash": detailFlash(r),
|
data["Flash"] = detailFlash(r)
|
||||||
"Wildcard": store.AddressModeWildcard,
|
data["Wildcard"] = store.AddressModeWildcard
|
||||||
"List": store.AddressModeList,
|
data["List"] = store.AddressModeList
|
||||||
"RateLimitErr": view.RateLimitErr,
|
data["RateLimitErr"] = view.RateLimitErr
|
||||||
"ExportErr": view.ExportErr,
|
data["ExportErr"] = view.ExportErr
|
||||||
"MinPwLen": minSecretFilePasswordLen,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
|
||||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||||
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||||
})
|
h.view.Render(w, status, "domain_detail", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -149,31 +177,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
// and no extra environment variable is needed. That server result is returned
|
// and no extra environment variable is needed. That server result is returned
|
||||||
// alongside, because the page's suggested SPF record is built from the same
|
// alongside, because the page's suggested SPF record is built from the same
|
||||||
// addresses. force bypasses the cache, for the Re-check button.
|
// addresses. force bypasses the cache, for the Re-check button.
|
||||||
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
|
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
return s.dns.Domain(dnscheck.Query{
|
return h.dns.Domain(dnscheck.Query{
|
||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
Selector: d.DKIMSelector,
|
Selector: d.DKIMSelector,
|
||||||
ExpectedDKIM: record.Value,
|
ExpectedDKIM: record.Value,
|
||||||
Hostname: srv.Hostname,
|
Hostname: srv.Hostname,
|
||||||
ServerIPs: srv.IPs,
|
ServerIPs: srv.IPs,
|
||||||
|
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||||
}, force), srv
|
}, force), srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
||||||
// returns to its page, which then renders the fresh result.
|
// returns to its page, which then renders the fresh result.
|
||||||
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record, err := s.domains.DKIMRecord(d)
|
record, err := h.domains.DKIMRecord(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.domainDNS(d, record, true)
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +240,8 @@ func detailFlash(r *http.Request) string {
|
|||||||
return "Application address mode updated."
|
return "Application address mode updated."
|
||||||
case r.URL.Query().Get("ratelimit") != "":
|
case r.URL.Query().Get("ratelimit") != "":
|
||||||
return "Rate limit updated."
|
return "Rate limit updated."
|
||||||
|
case r.URL.Query().Get("dmarc") != "":
|
||||||
|
return "DMARC report settings updated."
|
||||||
case r.URL.Query().Get("imported") != "":
|
case r.URL.Query().Get("imported") != "":
|
||||||
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
||||||
case r.URL.Query().Get("rechecked") != "":
|
case r.URL.Query().Get("rechecked") != "":
|
||||||
@@ -214,17 +251,17 @@ func detailFlash(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAddApplication creates an application on a domain and renders the page
|
// HandleAddApplication creates an application on a domain and renders the page
|
||||||
// back with the generated password shown once (product.md, security.md). Because the
|
// back with the generated password shown once (product.md, security.md). Because the
|
||||||
// password cannot be recovered later, this deliberately renders inline rather
|
// password cannot be recovered later, this deliberately renders inline rather
|
||||||
// than redirecting.
|
// than redirecting.
|
||||||
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
||||||
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,25 +275,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
|||||||
FormAddrs: r.PostFormValue("addresses"),
|
FormAddrs: r.PostFormValue("addresses"),
|
||||||
}
|
}
|
||||||
|
|
||||||
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
|
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repopulate.FormErr = applicationErrorMessage(err)
|
repopulate.FormErr = applicationErrorMessage(err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if errors.Is(err, store.ErrLoginExists) {
|
if errors.Is(err, store.ErrLoginExists) {
|
||||||
status = http.StatusConflict
|
status = http.StatusConflict
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, status, d, repopulate)
|
h.renderDomainDetail(w, r, status, d, repopulate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
NewCred: &newCred{Login: a.Login, Password: password},
|
NewCred: &newCred{Login: a.Login, Password: password},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUpdateAppMode switches an application's address mode / list (product.md).
|
// HandleUpdateAppMode switches an application's address mode / list (product.md).
|
||||||
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -267,13 +304,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
mode := r.PostFormValue("mode")
|
mode := r.PostFormValue("mode")
|
||||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||||
|
|
||||||
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
||||||
d, derr := s.domains.Get(a.DomainID)
|
d, derr := h.domains.Get(a.DomainID)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
})
|
})
|
||||||
@@ -282,38 +319,38 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRegenPassword issues a new password for an application and shows it once
|
// HandleRegenPassword issues a new password for an application and shows it once
|
||||||
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
|
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
|
||||||
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.domains.Get(a.DomainID)
|
d, err := h.domains.Get(a.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
password, err := s.apps.RegeneratePassword(a.ID)
|
password, err := h.apps.RegeneratePassword(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
NewCred: &newCred{Login: a.Login, Password: password},
|
NewCred: &newCred{Login: a.Login, Password: password},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteApplication removes an application and returns to its domain page
|
// HandleDeleteApplication removes an application and returns to its domain page
|
||||||
// (product.md).
|
// (product.md).
|
||||||
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.apps.Delete(a.ID); err != nil {
|
if err := h.apps.Delete(a.ID); err != nil {
|
||||||
logf("panel: delete application %d: %v", a.ID, err)
|
logf("panel: delete application %d: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -323,13 +360,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// lookupApplication resolves the {aid} path value to an application, writing a
|
// lookupApplication resolves the {aid} path value to an application, writing a
|
||||||
// 404 for a bad id or missing application.
|
// 404 for a bad id or missing application.
|
||||||
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return store.Application{}, false
|
return store.Application{}, false
|
||||||
}
|
}
|
||||||
a, err := s.apps.Get(id)
|
a, err := h.apps.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrApplicationNotFound) {
|
if errors.Is(err, store.ErrApplicationNotFound) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -339,6 +376,11 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,6 +13,7 @@ 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/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
||||||
@@ -22,35 +23,37 @@ import (
|
|||||||
// covers both forms.
|
// covers both forms.
|
||||||
const maxImportBytes = 1 << 20 // 1 MiB
|
const maxImportBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
// handleBackupPage renders the backup/migration screen: the full-server backup
|
// HandleBackupPage renders the backup/migration screen: the full-server backup
|
||||||
// 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 (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderBackupPage(w, r, http.StatusOK, "")
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderBackupPage(w, r, http.StatusOK, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||||
// (architecture.md § Persistence) next to the form that produced it.
|
// (architecture.md § Persistence) next to the form that produced it.
|
||||||
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||||
s.renderBackupPageWith(w, r, status, importErr, "")
|
h.renderBackupPageWith(w, r, status, importErr, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
||||||
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
||||||
// 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 (s *Server) 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) {
|
||||||
s.render(w, status, "backup", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — backup",
|
data["Title"] = "SelfPost — backup"
|
||||||
"User": currentUser(r),
|
data["Active"] = "backup"
|
||||||
"Active": "backup",
|
data["ImportErr"] = importErr
|
||||||
"ImportErr": importErr,
|
data["BackupErr"] = backupErr
|
||||||
"BackupErr": backupErr,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"MinPwLen": 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 §
|
||||||
// Persistence). It is an authenticated admin action (this handler sits behind
|
// Persistence). It is an authenticated admin action (this handler sits behind
|
||||||
// the auth middleware). The archive carries DKIM private keys, the admin
|
// the auth middleware). The archive carries DKIM private keys, the admin
|
||||||
// password hash and SASL credentials, so it is served with no-store and as an
|
// password hash and SASL credentials, so it is served with no-store and as an
|
||||||
@@ -58,10 +61,13 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st
|
|||||||
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
|
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
|
||||||
// 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 (s *Server) 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 != "" {
|
||||||
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +105,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := backup.Create(sink, backup.Params{
|
if err := backup.Create(sink, backup.Params{
|
||||||
DataDir: s.cfg.DataDir,
|
DataDir: h.cfg.DataDir,
|
||||||
DBPath: s.cfg.DBPath,
|
DBPath: h.cfg.DBPath,
|
||||||
Version: s.cfg.Version,
|
Version: h.cfg.Version,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logf("panel: full backup failed: %v", err)
|
logf("panel: full backup failed: %v", err)
|
||||||
return
|
return
|
||||||
@@ -113,26 +119,26 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExportDomain streams a single-domain export as a secret download
|
// HandleExportDomain streams a single-domain export as a secret download
|
||||||
// (architecture.md § Persistence). Like the full backup it is POST-only (state
|
// (architecture.md § Persistence). Like the full backup it is POST-only (state
|
||||||
// is not changed, but the response contains the domain's DKIM private key and
|
// is not changed, but the response contains the domain's DKIM private key and
|
||||||
// application passwords, so it must not be prefetchable or cached). Like the
|
// application passwords, so it must not be prefetchable or cached). Like the
|
||||||
// full backup it can be encrypted with a password, in which case the download
|
// full backup it can be encrypted with a password, in which case the download
|
||||||
// is a .spde envelope instead of plain JSON.
|
// is a .spde envelope instead of plain JSON.
|
||||||
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
password, pwErr := secretFilePassword(r)
|
password, pwErr := secretFilePassword(r)
|
||||||
if pwErr != "" {
|
if pwErr != "" {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
ExportErr: pwErr,
|
ExportErr: pwErr,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exp, err := s.domains.Export(d.ID)
|
exp, err := h.domains.Export(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: export domain %d: %v", d.ID, err)
|
logf("panel: export domain %d: %v", d.ID, err)
|
||||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||||
@@ -174,22 +180,25 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleImportDomain accepts an uploaded domain-export file and re-creates the
|
// HandleImportDomain accepts an uploaded domain-export file and re-creates the
|
||||||
// domain on this instance (architecture.md § Persistence). The domain name is
|
// domain on this instance (architecture.md § Persistence). The domain name is
|
||||||
// normalised and validated here (security.md); the domain service validates
|
// normalised and validated here (security.md); the domain service validates
|
||||||
// the selector, each login and address, and the DKIM key before writing
|
// the selector, each login and address, and the DKIM key before writing
|
||||||
// anything. On success it redirects to the new domain's page; on failure it
|
// anything. On success it redirects to the new domain's page; on failure it
|
||||||
// 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 (s *Server) 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 {
|
||||||
s.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).")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, _, err := r.FormFile("file")
|
file, _, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@@ -201,7 +210,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
head := make([]byte, secretfile.MagicLen)
|
head := make([]byte, secretfile.MagicLen)
|
||||||
n, err := io.ReadFull(file, head)
|
n, err := io.ReadFull(file, head)
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
||||||
@@ -209,16 +218,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if secretfile.HasMagic(head[:n]) {
|
if secretfile.HasMagic(head[:n]) {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
env, err := secretfile.NewReader(source, password)
|
env, err := secretfile.NewReader(source, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if env.Type() != secretfile.TypeDomainExport {
|
if env.Type() != secretfile.TypeDomainExport {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Read the whole plaintext first: authentication of the last chunk is
|
// Read the whole plaintext first: authentication of the last chunk is
|
||||||
@@ -226,12 +235,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
// accept a truncated document before ever reaching it.
|
// accept a truncated document before ever reaching it.
|
||||||
plain, err := io.ReadAll(env)
|
plain, err := io.ReadAll(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
source = bytes.NewReader(plain)
|
source = bytes.NewReader(plain)
|
||||||
} else if password != "" {
|
} else if password != "" {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,23 +248,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
dec := json.NewDecoder(source)
|
dec := json.NewDecoder(source)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&exp); err != nil {
|
if err := dec.Decode(&exp); err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalise and validate the domain name before it reaches the service, the
|
// Normalise and validate the domain name before it reaches the service, the
|
||||||
// same gate the add-domain form uses (security.md).
|
// same gate the add-domain form uses (security.md).
|
||||||
exp.Domain = normalizeDomain(exp.Domain)
|
exp.Domain = validate.NormalizeDomain(exp.Domain)
|
||||||
if err := validateDomain(exp.Domain); err != nil {
|
if err := validate.Domain(exp.Domain); err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := s.domains.Import(exp)
|
d, err := h.domains.Import(exp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||||
status, msg := importErrorMessage(err)
|
status, msg := importErrorMessage(err)
|
||||||
s.renderBackupPage(w, r, status, msg)
|
h.renderBackupPage(w, r, status, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||||
@@ -276,8 +285,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
password = r.PostFormValue("password")
|
password = r.PostFormValue("password")
|
||||||
if len([]rune(password)) < minSecretFilePasswordLen {
|
if len([]rune(password)) < validate.MinSecretFilePasswordLen {
|
||||||
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
|
return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen)
|
||||||
}
|
}
|
||||||
if password != r.PostFormValue("password_confirm") {
|
if password != r.PostFormValue("password_confirm") {
|
||||||
return "", "The two passwords do not match."
|
return "", "The two passwords do not match."
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/secretfile"
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// postForm builds the kind of request the backup and export forms submit.
|
// postForm builds the kind of request the backup and export forms submit.
|
||||||
@@ -24,8 +25,8 @@ func postForm(values url.Values) *http.Request {
|
|||||||
// the archive is sealed — and leaving the box unticked has to keep producing
|
// the archive is sealed — and leaving the box unticked has to keep producing
|
||||||
// the plain archive earlier versions produced.
|
// the plain archive earlier versions produced.
|
||||||
func TestSecretFilePassword(t *testing.T) {
|
func TestSecretFilePassword(t *testing.T) {
|
||||||
long := strings.Repeat("x", minSecretFilePasswordLen)
|
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
|
||||||
short := strings.Repeat("x", minSecretFilePasswordLen-1)
|
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) {
|
|||||||
// forgets to include the partial (or the data it needs) loses the option
|
// forgets to include the partial (or the data it needs) loses the option
|
||||||
// silently, since the plain download still works.
|
// silently, since the plain download still works.
|
||||||
func TestBackupPageOffersEncryption(t *testing.T) {
|
func TestBackupPageOffersEncryption(t *testing.T) {
|
||||||
tmpl, err := loadTemplates()
|
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadTemplates: %v", err)
|
|
||||||
}
|
|
||||||
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
||||||
http.StatusOK, "", "The two passwords do not match.")
|
http.StatusOK, "", "The two passwords do not match.")
|
||||||
|
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
@@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
|
|||||||
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
||||||
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
||||||
"data-import-password-fields",
|
"data-import-password-fields",
|
||||||
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
|
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
|
||||||
"The two passwords do not match.",
|
"The two passwords do not match.",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleDomainDMARC saves per-domain DMARC rua= settings.
|
||||||
|
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := h.lookupDomain(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rua sql.NullString
|
||||||
|
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||||
|
case "inherit":
|
||||||
|
rua = sql.NullString{}
|
||||||
|
case "none":
|
||||||
|
rua = sql.NullString{Valid: true, String: ""}
|
||||||
|
case "custom":
|
||||||
|
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||||
|
if err := validate.Email(email); err != nil {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rua = sql.NullString{Valid: true, String: email}
|
||||||
|
default:
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||||
|
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||||
|
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dns.Forget(d.Name)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
||||||
|
// verdict of its published DNS records, so the operator sees which domains still
|
||||||
|
// need a record published without opening each one.
|
||||||
|
type domainRow struct {
|
||||||
|
store.Domain
|
||||||
|
DNS health.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboard is the authenticated landing page: the list of sending
|
||||||
|
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||||
|
// form (product.md).
|
||||||
|
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderDashboard(w, r, http.StatusOK, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domains, err := h.assignedDomains(p)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: dashboard: list domains: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost"
|
||||||
|
data["Active"] = "domains"
|
||||||
|
data["Domains"] = h.domainRows(domains)
|
||||||
|
data["Error"] = formErr
|
||||||
|
data["FormName"] = formName
|
||||||
|
data["Flash"] = dashboardFlash(r)
|
||||||
|
h.view.Render(w, status, "dashboard", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||||
|
profileEmail := ""
|
||||||
|
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||||
|
profileEmail = email
|
||||||
|
}
|
||||||
|
rows := make([]domainRow, len(domains))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, d := range domains {
|
||||||
|
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
record, err := h.domains.DKIMRecord(d)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dns, _ := h.domainDNS(d, record, profileEmail, false)
|
||||||
|
rows[i].DNS = dns.Overall
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardFlash(r *http.Request) string {
|
||||||
|
if r.URL.Query().Get("deleted") != "" {
|
||||||
|
return "Domain deleted."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||||||
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||||
|
// publish is shown (product.md).
|
||||||
|
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := r.PostFormValue("name")
|
||||||
|
name := validate.NormalizeDomain(raw)
|
||||||
|
if err := validate.Domain(name); err != nil {
|
||||||
|
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := h.domains.Add(name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainExists) {
|
||||||
|
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: add domain %q: %v", name, err)
|
||||||
|
h.renderDashboard(w, r, http.StatusInternalServerError,
|
||||||
|
"Could not add the domain. Please check the logs and try again.", raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||||
|
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := h.lookupDomain(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||||
|
"Title": "SelfPost — delete " + d.Name,
|
||||||
|
"User": auth.CurrentUser(r),
|
||||||
|
"Active": "domains",
|
||||||
|
"Domain": d,
|
||||||
|
"IsGlobal": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||||||
|
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseDomainID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d, err := h.domains.Get(id); err == nil {
|
||||||
|
defer h.dns.Forget(d.Name)
|
||||||
|
}
|
||||||
|
if err := h.domains.Delete(id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: delete domain %d: %v", id, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||||
|
// sender map on demand (architecture.md § Panel HTTP surface).
|
||||||
|
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.domains.Resync(); err != nil {
|
||||||
|
logf("panel: manual reload (opendkim): %v", err)
|
||||||
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.apps.Resync(); err != nil {
|
||||||
|
logf("panel: manual reload (postfix): %v", err)
|
||||||
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||||
|
id, ok := parseDomainID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
d, err := h.domains.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
logf("panel: get domain %d: %v", id, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok || !p.CanAccessDomain(d.ID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
||||||
@@ -24,38 +26,40 @@ const (
|
|||||||
deliveryLogLines = 200
|
deliveryLogLines = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleDeliveries renders the Deliveries page over the send log: server-side
|
// HandleDeliveries renders the Deliveries page over the send log: server-side
|
||||||
// filters by domain/application and pagination (architecture.md §
|
// filters by domain/application and pagination (architecture.md §
|
||||||
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
||||||
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
|
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
|
||||||
// refreshes never diverge.
|
// refreshes never diverge.
|
||||||
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.sendLogData(r)
|
data, err := h.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log: %v", err)
|
logf("panel: send log: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data["Title"] = "SelfPost — deliveries"
|
data["Title"] = "SelfPost — deliveries"
|
||||||
data["User"] = currentUser(r)
|
for k, v := range h.pageBase(r) {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
data["Active"] = "deliveries"
|
data["Active"] = "deliveries"
|
||||||
s.render(w, http.StatusOK, "deliveries", data)
|
h.view.Render(w, http.StatusOK, "deliveries", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
||||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
||||||
// JSON).
|
// JSON).
|
||||||
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.sendLogData(r)
|
data, err := h.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log rows: %v", err)
|
logf("panel: send log rows: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
|
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDelivery renders one send-log row in full. The log itself carries only
|
// HandleDelivery renders one send-log row in full. The log itself carries only
|
||||||
// what identifies a message at a glance — when, who to and from, what about,
|
// what identifies a message at a glance — when, who to and from, what about,
|
||||||
// how it ended — and every remaining field (domain, application, queue id, when
|
// how it ended — and every remaining field (domain, application, queue id, when
|
||||||
// the status was last reported) lives here, one page per row, so widening the
|
// the status was last reported) lives here, one page per row, so widening the
|
||||||
@@ -67,13 +71,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
|||||||
// its history side by side, and the mail.log lines for its queue id under both.
|
// its history side by side, and the mail.log lines for its queue id under both.
|
||||||
// The queue id used to be printed here as something to go and search the system
|
// The queue id used to be printed here as something to go and search the system
|
||||||
// log for by hand; the search is done for the operator instead.
|
// log for by hand; the search is done for the operator instead.
|
||||||
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := s.store.GetSendLog(id)
|
row, err := h.store.GetSendLog(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A row pruned on the retention window is gone, not broken.
|
// A row pruned on the retention window is gone, not broken.
|
||||||
if errors.Is(err, store.ErrSendLogNotFound) {
|
if errors.Is(err, store.ErrSendLogNotFound) {
|
||||||
@@ -84,12 +88,29 @@ func (s *Server) 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 := s.deliveryLog(row)
|
logRows, logNote := h.deliveryLog(row)
|
||||||
s.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": 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.
|
||||||
@@ -223,11 +244,11 @@ type deliveryLogRow struct {
|
|||||||
// have aged out of the log — so none of them is an error on the page. Only a
|
// have aged out of the log — so none of them is an error on the page. Only a
|
||||||
// log that cannot be read at all is reported as a fault, and that one is
|
// log that cannot be read at all is reported as a fault, and that one is
|
||||||
// logged for the operator as well.
|
// logged for the operator as well.
|
||||||
func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
||||||
if row.QueueID == "" {
|
if row.QueueID == "" {
|
||||||
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
||||||
}
|
}
|
||||||
lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
logf("panel: delivery log %s: %v", row.QueueID, err)
|
logf("panel: delivery log %s: %v", row.QueueID, err)
|
||||||
return nil, "Could not read the mail log."
|
return nil, "Could not read the mail log."
|
||||||
@@ -267,39 +288,69 @@ func deliveriesBackURL(r *http.Request) string {
|
|||||||
// sendLogData reads the domain/app filters and page number off the query
|
// sendLogData reads the domain/app filters and page number off the query
|
||||||
// string, queries the store, and assembles everything the template needs
|
// string, queries the store, and assembles everything the template needs
|
||||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||||
func (s *Server) 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 := s.store.CountSendLog(filter)
|
total, err := h.store.CountSendLog(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||||
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
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
domains, err := s.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 := s.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
|
||||||
@@ -331,23 +382,30 @@ func parsePage(v string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
||||||
// surface).
|
// surface).
|
||||||
func (s *Server) 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()
|
||||||
s.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": 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 (s *Server) 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()
|
||||||
s.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,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
@@ -365,30 +423,37 @@ func readQueue() (string, string) {
|
|||||||
return out, ""
|
return out, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
s.render(w, http.StatusOK, "system_log", map[string]any{
|
return
|
||||||
"Title": "SelfPost — system log",
|
}
|
||||||
"User": currentUser(r),
|
lines, errText := h.readLogTail()
|
||||||
"Active": "system_log",
|
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
||||||
"Lines": lines,
|
"Title": "SelfPost — system log",
|
||||||
"Error": errText,
|
"User": auth.CurrentUser(r),
|
||||||
|
"Active": "system_log",
|
||||||
|
"IsGlobal": true,
|
||||||
|
"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 (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
return
|
||||||
|
}
|
||||||
|
lines, errText := h.readLogTail()
|
||||||
|
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
||||||
"Lines": lines,
|
"Lines": lines,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) readLogTail() ([]string, string) {
|
func (h *Handlers) readLogTail() ([]string, string) {
|
||||||
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
|
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
// Rotation renamed the file away; Postfix recreates it on reload
|
// Rotation renamed the file away; Postfix recreates it on reload
|
||||||
@@ -396,7 +461,7 @@ func (s *Server) readLogTail() ([]string, string) {
|
|||||||
// than a failure worth alarming the operator about.
|
// than a failure worth alarming the operator about.
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
|
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
|
||||||
return nil, "Could not read the mail log."
|
return nil, "Could not read the mail log."
|
||||||
}
|
}
|
||||||
return lines, ""
|
return lines, ""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,15 +10,16 @@ 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
|
||||||
// recreate it on reload (spec B.2); a missing file in that window is a normal,
|
// recreate it on reload (spec B.2); a missing file in that window is a normal,
|
||||||
// transient gap, not an operator-facing failure.
|
// transient gap, not an operator-facing failure.
|
||||||
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
||||||
s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
||||||
|
|
||||||
lines, errText := s.readLogTail()
|
lines, errText := h.readLogTail()
|
||||||
if lines != nil {
|
if lines != nil {
|
||||||
t.Errorf("lines = %v, want nil", lines)
|
t.Errorf("lines = %v, want nil", lines)
|
||||||
}
|
}
|
||||||
@@ -31,9 +32,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
|||||||
// when, from, to, subject and status, and links each row to the page carrying
|
// when, from, to, subject and status, and links each row to the page carrying
|
||||||
// the rest. A column added back here is one the table has no width for.
|
// the rest. A column added back here is one the table has no width for.
|
||||||
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDeliveries, "/deliveries")
|
out := getBody(t, h.HandleDeliveries, "/deliveries")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
"noreply@bs.example.ru", "public@example.ru",
|
"noreply@bs.example.ru", "public@example.ru",
|
||||||
@@ -56,11 +57,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
|||||||
// decoded them, and those rows are still in the send log. Decoding on the way
|
// decoded them, and those rows are still in the send log. Decoding on the way
|
||||||
// out is what keeps them readable, so the encoding must not survive to the page.
|
// out is what keeps them readable, so the encoding must not survive to the page.
|
||||||
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
|
|
||||||
for name, out := range map[string]string{
|
for name, out := range map[string]string{
|
||||||
"log": getBody(t, s.handleDeliveries, "/deliveries"),
|
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
|
||||||
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
|
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
|
||||||
} {
|
} {
|
||||||
if strings.Contains(out, "=?utf-8?Q?") {
|
if strings.Contains(out, "=?utf-8?Q?") {
|
||||||
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
||||||
@@ -74,9 +75,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
|||||||
// Everything the log dropped has to be somewhere, and that somewhere is the
|
// Everything the log dropped has to be somewhere, and that somewhere is the
|
||||||
// per-row page — including for a row still holding an encoded subject.
|
// per-row page — including for a row still holding an encoded subject.
|
||||||
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
||||||
"noreply@bs.example.ru", "public@example.ru", "sent",
|
"noreply@bs.example.ru", "public@example.ru", "sent",
|
||||||
@@ -95,9 +96,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
|||||||
// journal holds, stated as the steps they stand for, so a row is readable as
|
// journal holds, stated as the steps they stand for, so a row is readable as
|
||||||
// what happened to the message rather than as a list of fields.
|
// what happened to the message rather than as a list of fields.
|
||||||
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Accepted and queued", "Delivered",
|
"Accepted and queued", "Delivered",
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
@@ -121,19 +122,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
|||||||
// for is drawn as one that has not happened rather than dated with the moment
|
// for is drawn as one that has not happened rather than dated with the moment
|
||||||
// the row was written.
|
// the row was written.
|
||||||
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
if err := s.store.InsertQueued(store.SendLogEntry{
|
if err := h.store.InsertQueued(store.SendLogEntry{
|
||||||
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||||
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||||
if err != nil || len(rows) != 1 {
|
if err != nil || len(rows) != 1 {
|
||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
||||||
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
||||||
@@ -146,14 +147,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
|||||||
// lines — as a table of when and what, so the seconds between the connection
|
// lines — as a table of when and what, so the seconds between the connection
|
||||||
// and the reply line up down one edge.
|
// and the reply line up down one edge.
|
||||||
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = writeMailLog(t,
|
h.cfg.MailLogPath = writeMailLog(t,
|
||||||
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
||||||
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
||||||
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
||||||
)
|
)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"<th>Time</th>", "<th>Message</th>",
|
"<th>Time</th>", "<th>Message</th>",
|
||||||
// The stamp is split off into its own cell, without the microseconds
|
// The stamp is split off into its own cell, without the microseconds
|
||||||
@@ -174,10 +175,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
|||||||
// A line whose head is not a timestamp still has to show in full; the format is
|
// A line whose head is not a timestamp still has to show in full; the format is
|
||||||
// the log's, not ours, and a line we cannot split is a line we must not drop.
|
// the log's, not ours, and a line we cannot split is a line we must not drop.
|
||||||
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
|
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
|
||||||
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
|
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -186,10 +187,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
|||||||
// Rows outlive mail.log, and a message the milter refused never reached the
|
// Rows outlive mail.log, and a message the milter refused never reached the
|
||||||
// queue at all. Neither is a fault, so neither may render as an error.
|
// queue at all. Neither is a fault, so neither may render as an error.
|
||||||
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
if !strings.Contains(out, "rotated away") {
|
if !strings.Contains(out, "rotated away") {
|
||||||
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -201,13 +202,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
|||||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||||
// that no longer exists is a 404, not a 500.
|
// that no longer exists is a 404, not a 500.
|
||||||
func TestDeliveryPageNotFound(t *testing.T) {
|
func TestDeliveryPageNotFound(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
|
|
||||||
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
||||||
s.handleDelivery(rec, req)
|
h.HandleDelivery(rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,7 @@ func TestDeliveryPageNotFound(t *testing.T) {
|
|||||||
|
|
||||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||||
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
|
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,11 +243,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
|
|||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := loadTemplates()
|
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadTemplates: %v", err)
|
|
||||||
}
|
|
||||||
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
// getBody runs one handler over a GET and returns the page it wrote, failing
|
||||||
@@ -256,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)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,15 +10,8 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultRateLimitWindowSeconds is the sliding-window length used when an
|
|
||||||
// admin sets a message ceiling but leaves the window blank (guide § Rate
|
|
||||||
// limiting, matching the level-1 default hour; guide § Environment variables:
|
|
||||||
// RATE_LIMIT_WINDOW_SECONDS).
|
|
||||||
const defaultRateLimitWindowSeconds = 3600
|
const defaultRateLimitWindowSeconds = 3600
|
||||||
|
|
||||||
// rateLimitInput is the validated result of a rate-limit form submission.
|
|
||||||
// clear means "remove the differentiated limit" (guide § Rate limiting: an
|
|
||||||
// empty IP binding leaves only level 1).
|
|
||||||
type rateLimitInput struct {
|
type rateLimitInput struct {
|
||||||
clear bool
|
clear bool
|
||||||
ips []string
|
ips []string
|
||||||
@@ -26,10 +19,6 @@ type rateLimitInput struct {
|
|||||||
windowSeconds int
|
windowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRateLimitForm validates a rate-limit submission on the server
|
|
||||||
// (security.md). It returns clear=true when the admin removes the limit or
|
|
||||||
// leaves the IP binding empty; otherwise it requires a positive ceiling and
|
|
||||||
// window. The returned error's message is safe to show to the admin.
|
|
||||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
func parseRateLimitForm(r *http.Request) (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")
|
||||||
@@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
|||||||
return rateLimitInput{}, err
|
return rateLimitInput{}, err
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
// No IP binding: the differentiated limit does not apply (guide § Rate
|
|
||||||
// limiting).
|
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{clear: true}, nil
|
||||||
}
|
}
|
||||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
||||||
@@ -57,10 +44,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
|||||||
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
|
|
||||||
// whitespace) into a deduplicated list of canonical addresses, rejecting any
|
|
||||||
// token that is not a valid IP (security.md). The values are only ever stored as
|
|
||||||
// SQLite parameters and compared in the milter, never written to a config file.
|
|
||||||
func parseIPList(raw string) ([]string, error) {
|
func parseIPList(raw string) ([]string, error) {
|
||||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||||||
@@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePositiveInt parses a trimmed integer field, returning def when it is
|
|
||||||
// blank. A non-numeric value returns an error.
|
|
||||||
func parsePositiveInt(raw string, def int) (int, error) {
|
func parsePositiveInt(raw string, def int) (int, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) {
|
|||||||
return strconv.Atoi(raw)
|
return strconv.Atoi(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainRateLimit saves or clears a domain-level differentiated rate
|
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
// limit (guide § Rate limiting). No reload is needed — the milter reads the
|
d, ok := h.lookupDomain(w, r)
|
||||||
// row live.
|
|
||||||
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
d, ok := s.lookupDomain(w, r)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseRateLimitForm(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
RateLimitErr: err.Error(),
|
RateLimitErr: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
|
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
|
||||||
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -115,27 +93,25 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAppRateLimit saves or clears an application-level differentiated rate
|
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
// limit (guide § Rate limiting).
|
a, ok := h.lookupApplication(w, r)
|
||||||
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := s.lookupApplication(w, r)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.domains.Get(a.DomainID)
|
d, err := h.domains.Get(a.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseRateLimitForm(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
|
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
|
||||||
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -143,9 +119,7 @@ func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyRateLimit dispatches a validated input to the save or clear method of the
|
func (h *Handlers) applyRateLimit(
|
||||||
// relevant service, keyed by the domain or application id.
|
|
||||||
func (s *Server) applyRateLimit(
|
|
||||||
in rateLimitInput,
|
in rateLimitInput,
|
||||||
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
||||||
clear func(id int64) error,
|
clear func(id int64) error,
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.statusBody()
|
||||||
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
|
|
||||||
|
data["Title"] = "SelfPost — status"
|
||||||
|
data["User"] = auth.CurrentUser(r)
|
||||||
|
data["Active"] = "status"
|
||||||
|
data["IsGlobal"] = true
|
||||||
|
data["Flash"] = statusFlash(r)
|
||||||
|
data["Hostname"] = h.cfg.Hostname
|
||||||
|
data["PTR"] = srv.PTR
|
||||||
|
h.view.Render(w, http.StatusOK, "status", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dns.Server(h.cfg.Hostname, true)
|
||||||
|
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) statusBody() map[string]any {
|
||||||
|
procs, procErr := health.Processes()
|
||||||
|
procStatus := health.StatusUnknown
|
||||||
|
if procErr != nil {
|
||||||
|
logf("panel: status: supervisorctl: %v", procErr)
|
||||||
|
} else {
|
||||||
|
for _, p := range procs {
|
||||||
|
procStatus = health.Worst(procStatus, p.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queueText, queueErr := readQueue()
|
||||||
|
queueStatus := health.StatusOK
|
||||||
|
if queueErr != "" {
|
||||||
|
queueStatus = health.StatusWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := health.CheckCertificate(h.cfg.TLSCertFile)
|
||||||
|
sockets := []health.Socket{
|
||||||
|
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
|
||||||
|
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
|
||||||
|
}
|
||||||
|
socketStatus := health.StatusUnknown
|
||||||
|
for _, sock := range sockets {
|
||||||
|
socketStatus = health.Worst(socketStatus, sock.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
machine := h.machine.Sample()
|
||||||
|
|
||||||
|
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||||
|
return map[string]any{
|
||||||
|
"Processes": procs,
|
||||||
|
"ProcessError": procErr != nil,
|
||||||
|
"ProcessStatus": procStatus,
|
||||||
|
"QueueSummary": queueSummary(queueText),
|
||||||
|
"QueueError": queueErr,
|
||||||
|
"QueueStatus": queueStatus,
|
||||||
|
"Machine": machine,
|
||||||
|
"Cert": cert,
|
||||||
|
"Sockets": sockets,
|
||||||
|
"SocketStatus": socketStatus,
|
||||||
|
"OverallStatus": overall,
|
||||||
|
"OverallHeading": overallHeading(overall),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueSummary(out string) string {
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func overallHeading(worst health.Status) string {
|
||||||
|
switch worst {
|
||||||
|
case health.StatusError:
|
||||||
|
return "A component needs attention — see the details below."
|
||||||
|
case health.StatusWarn:
|
||||||
|
return "Running, with warnings below."
|
||||||
|
case health.StatusOK:
|
||||||
|
return "All components are running normally."
|
||||||
|
default:
|
||||||
|
return "Some checks could not be performed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFlash(r *http.Request) string {
|
||||||
|
switch {
|
||||||
|
case r.URL.Query().Get("reloaded") != "":
|
||||||
|
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
|
||||||
|
case r.URL.Query().Get("rechecked") != "":
|
||||||
|
return "DNS re-checked."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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)
|
||||||
|
data["Title"] = "SelfPost — user"
|
||||||
|
data["Active"] = "users"
|
||||||
|
data["UserID"] = userID
|
||||||
|
data["Domains"] = domains
|
||||||
|
data["Error"] = view.FormErr
|
||||||
|
data["FormUsername"] = view.FormUsername
|
||||||
|
data["FormRole"] = view.FormRole
|
||||||
|
data["FormDomains"] = view.FormDomains
|
||||||
|
data["FormPassword"] = view.FormPassword
|
||||||
|
data["IsEdit"] = userID != 0
|
||||||
|
h.view.Render(w, status, "user_form", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustView(t *testing.T) *view.Engine {
|
||||||
|
t.Helper()
|
||||||
|
v, err := view.New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleAccount serves the administrator's own account settings: the username
|
|
||||||
// and password chosen during setup are the only panel credentials
|
|
||||||
// (security.md), and until now they could be changed only by recreating the
|
|
||||||
// state. Changing them here never touches application SASL logins, which are a
|
|
||||||
// separate identity system (architecture.md § Mail path).
|
|
||||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitAccount(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderAccount draws the settings form. formUsername repopulates the username
|
|
||||||
// field after a rejected submission; the password fields are never repopulated.
|
|
||||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
|
||||||
s.render(w, status, "account", map[string]any{
|
|
||||||
"Title": "SelfPost — account",
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "account",
|
|
||||||
"FormUsername": formUsername,
|
|
||||||
"Error": formErr,
|
|
||||||
"Flash": accountFlash(r),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func accountFlash(r *http.Request) string {
|
|
||||||
switch r.URL.Query().Get("updated") {
|
|
||||||
case "username":
|
|
||||||
return "Username changed."
|
|
||||||
case "password":
|
|
||||||
return "Password changed. Any other signed-in sessions were signed out."
|
|
||||||
case "both":
|
|
||||||
return "Username and password changed. Any other signed-in sessions were signed out."
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
s.renderAccount(w, r, http.StatusTooManyRequests,
|
|
||||||
"Too many attempts. Please wait and try again.", currentUser(r))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
current := r.PostFormValue("current_password")
|
|
||||||
password := r.PostFormValue("new_password")
|
|
||||||
confirm := r.PostFormValue("new_password_confirm")
|
|
||||||
|
|
||||||
admin, err := s.store.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: account: get admin failed: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if username == "" {
|
|
||||||
username = admin.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renaming := username != admin.Username
|
|
||||||
if renaming {
|
|
||||||
if err := validateUsername(username); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty pair of new-password fields means "leave the password alone", so
|
|
||||||
// the username can be changed on its own.
|
|
||||||
repassword := password != "" || confirm != ""
|
|
||||||
if repassword {
|
|
||||||
if password != confirm {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateAdminPassword(password); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !renaming && !repassword {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest,
|
|
||||||
"Nothing to change: enter a new username, a new password, or both.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := admin.PasswordHash
|
|
||||||
if repassword {
|
|
||||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: account: hashing password failed: %v", err)
|
|
||||||
s.renderAccount(w, r, http.StatusInternalServerError,
|
|
||||||
"Internal error. Please try again.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash = string(newHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateAdmin(username, hash); err != nil {
|
|
||||||
logf("panel: account: update admin failed: %v", err)
|
|
||||||
msg := "Could not save the changes. Please check the logs and try again."
|
|
||||||
if errors.Is(err, store.ErrNoAdmin) {
|
|
||||||
msg = "There is no administrator account to update."
|
|
||||||
}
|
|
||||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep this session usable under the new name, and — when the password
|
|
||||||
// changed — drop every other session so a cookie captured under the old
|
|
||||||
// password stops working.
|
|
||||||
if token, ok := s.sessionToken(r); ok {
|
|
||||||
if renaming {
|
|
||||||
s.sessions.Rename(token, username)
|
|
||||||
}
|
|
||||||
if repassword {
|
|
||||||
s.sessions.DestroyOthers(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
|
|
||||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updatedFlag names what changed, for the fixed post-redirect flash message.
|
|
||||||
func updatedFlag(renamed, repassword bool) string {
|
|
||||||
switch {
|
|
||||||
case renamed && repassword:
|
|
||||||
return "both"
|
|
||||||
case renamed:
|
|
||||||
return "username"
|
|
||||||
default:
|
|
||||||
return "password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The panel session cookie's two possible names. In the production shape —
|
|
||||||
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
|
|
||||||
// what the cookie's attributes merely promise into something the browser
|
|
||||||
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
|
|
||||||
// attribute, so no other host may set a cookie by this name.
|
|
||||||
// The prefix is only valid on a Secure cookie, so a development instance on
|
|
||||||
// plain HTTP has to keep the bare name: with the prefix the browser would
|
|
||||||
// discard the Set-Cookie outright and logging in would silently never stick.
|
|
||||||
const (
|
|
||||||
sessionCookieBase = "selfpost_session"
|
|
||||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionCookie is the session cookie's name for this deployment.
|
|
||||||
func (s *Server) sessionCookie() string {
|
|
||||||
if s.cfg.CookieSecure {
|
|
||||||
return sessionCookiePrefixed
|
|
||||||
}
|
|
||||||
return sessionCookieBase
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionToken returns the session token the request carries, if exactly one
|
|
||||||
// cookie of that name is present.
|
|
||||||
//
|
|
||||||
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
|
|
||||||
// the first match. Two cookies with the same name mean somebody other than
|
|
||||||
// this panel set one of them — a host on the same registrable domain can,
|
|
||||||
// with Domain=example.com, and the browser will then send both — and RFC 6265
|
|
||||||
// makes the older one come first, so "the first match" is precisely the
|
|
||||||
// attacker's. The value cannot be forged into a valid session, so the effect
|
|
||||||
// is denial of service, not compromise; refusing the request and saying so in
|
|
||||||
// the log is what makes it diagnosable instead of an endless login loop. The
|
|
||||||
// __Host- prefix prevents this outright, but only where it applies — this
|
|
||||||
// check also covers the plain-HTTP development shape.
|
|
||||||
func (s *Server) sessionToken(r *http.Request) (string, bool) {
|
|
||||||
name := s.sessionCookie()
|
|
||||||
var token string
|
|
||||||
var n int
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
if c.Name == name {
|
|
||||||
n++
|
|
||||||
token = c.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch n {
|
|
||||||
case 0:
|
|
||||||
return "", false
|
|
||||||
case 1:
|
|
||||||
return token, true
|
|
||||||
default:
|
|
||||||
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
|
||||||
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
|
||||||
r.Method, r.URL.Path, n, name)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearSessionCookies expires the session cookie under both names, so an
|
|
||||||
// upgrade that switches to the __Host- prefix does not leave the old cookie
|
|
||||||
// sitting in the browser until it is closed.
|
|
||||||
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
|
|
||||||
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: name,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
// The prefixed name is only accepted at all when Secure is set,
|
|
||||||
// including on the expiring copy.
|
|
||||||
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogin serves the login form (GET) and authenticates (POST). Until an
|
|
||||||
// administrator exists there is nobody to log in, so it points at setup.
|
|
||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
exists, err := s.store.AdminExists()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
// No admin yet: login is meaningless. Send a clear message rather than
|
|
||||||
// a failing form.
|
|
||||||
s.render(w, http.StatusOK, "login", map[string]any{
|
|
||||||
"Title": "SelfPost — Sign in",
|
|
||||||
"Active": "login",
|
|
||||||
"SetupHint": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderLogin(w, http.StatusOK, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitLogin(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
|
||||||
// Active names the page for the layout even though no navigation is drawn
|
|
||||||
// here: it is what puts page-login on <main>, which the stylesheet uses to
|
|
||||||
// give the signed-out pages a column the width of their own card.
|
|
||||||
s.render(w, status, "login", map[string]any{
|
|
||||||
"Title": "SelfPost — Sign in",
|
|
||||||
"Active": "login",
|
|
||||||
"Error": formErr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Brute-force throttle by client IP (security.md).
|
|
||||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
password := r.PostFormValue("password")
|
|
||||||
|
|
||||||
admin, err := s.store.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, store.ErrNoAdmin) {
|
|
||||||
logf("panel: login: get admin failed: %v", err)
|
|
||||||
}
|
|
||||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always run bcrypt so timing does not distinguish "wrong user" from
|
|
||||||
// "wrong password", and compare the username too.
|
|
||||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
|
||||||
if username != admin.Username || pwErr != nil {
|
|
||||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := s.sessions.Create(admin.Username)
|
|
||||||
s.setSessionCookie(w, token)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
|
|
||||||
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
|
|
||||||
// whatever the database row was just set to — at login, and again whenever
|
|
||||||
// requireAuth extends an active session.
|
|
||||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: s.sessionCookie(),
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: s.sessions.MaxAge(),
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: s.cfg.CookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogout destroys the session and clears the cookie.
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.Header().Set("Allow", "POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Destroy every token presented under the session cookie's name: if a
|
|
||||||
// shadowing duplicate is present (see sessionToken) one of them is the
|
|
||||||
// real session, and a value that names no session is simply not found.
|
|
||||||
name := s.sessionCookie()
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
if c.Name == name {
|
|
||||||
s.sessions.Destroy(c.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.clearSessionCookies(w)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
|
||||||
// verdict of its published DNS records, so the operator sees which domains still
|
|
||||||
// need a record published without opening each one.
|
|
||||||
type domainRow struct {
|
|
||||||
store.Domain
|
|
||||||
DNS health.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDashboard is the authenticated landing page: the list of sending
|
|
||||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
|
||||||
// form (product.md).
|
|
||||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderDashboard(w, r, http.StatusOK, "", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDashboard renders the domain list. formErr and formName repopulate the
|
|
||||||
// add-domain form after a rejected submission; flash surfaces a one-shot status
|
|
||||||
// message keyed by a redirect query flag (never reflected user input).
|
|
||||||
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
|
||||||
domains, err := s.domains.List()
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: dashboard: list domains: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.render(w, status, "dashboard", map[string]any{
|
|
||||||
"Title": "SelfPost",
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "domains",
|
|
||||||
"Domains": s.domainRows(domains),
|
|
||||||
"Error": formErr,
|
|
||||||
"FormName": formName,
|
|
||||||
"Flash": dashboardFlash(r),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainRows attaches each domain's DNS verdict to its row. The checks run
|
|
||||||
// concurrently rather than one after another: each carries its own timeout, so
|
|
||||||
// in series a dead resolver would multiply that wait by the number of domains
|
|
||||||
// and the list would look hung. The checker caches results for a few minutes,
|
|
||||||
// so a repeat view of the list costs no lookups at all, and it is the same
|
|
||||||
// cache the domain page fills — opening a domain after the list is free.
|
|
||||||
func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
|
||||||
rows := make([]domainRow, len(domains))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i, d := range domains {
|
|
||||||
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
record, err := s.domains.DKIMRecord(d)
|
|
||||||
if err != nil {
|
|
||||||
// Without the expected key there is nothing to compare DNS
|
|
||||||
// against; leave the row unknown rather than accusing the
|
|
||||||
// domain of a misconfiguration this server caused.
|
|
||||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dns, _ := s.domainDNS(d, record, false)
|
|
||||||
rows[i].DNS = dns.Overall
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func dashboardFlash(r *http.Request) string {
|
|
||||||
if r.URL.Query().Get("deleted") != "" {
|
|
||||||
return "Domain deleted."
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
|
|
||||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
|
||||||
// publish is shown (product.md).
|
|
||||||
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
raw := r.PostFormValue("name")
|
|
||||||
name := normalizeDomain(raw)
|
|
||||||
if err := validateDomain(name); err != nil {
|
|
||||||
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := s.domains.Add(name)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainExists) {
|
|
||||||
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: add domain %q: %v", name, err)
|
|
||||||
s.renderDashboard(w, r, http.StatusInternalServerError,
|
|
||||||
"Could not add the domain. Please check the logs and try again.", raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
|
|
||||||
// panel must explicitly state that all bound applications go with it (product.md).
|
|
||||||
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
||||||
d, ok := s.lookupDomain(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.render(w, http.StatusOK, "domain_delete", map[string]any{
|
|
||||||
"Title": "SelfPost — delete " + d.Name,
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "domains",
|
|
||||||
"Domain": d,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
|
|
||||||
// and returns to the domain list.
|
|
||||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseDomainID(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Drop any cached DNS verdict for the name while it is still resolvable, so
|
|
||||||
// re-adding the domain later starts from a fresh check instead of a stale
|
|
||||||
// one from before it was removed.
|
|
||||||
if d, err := s.domains.Get(id); err == nil {
|
|
||||||
defer s.dns.Forget(d.Name)
|
|
||||||
}
|
|
||||||
if err := s.domains.Delete(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainNotFound) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: delete domain %d: %v", id, err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
|
||||||
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
|
|
||||||
// regenerates its files from the database and reloads its daemon, so the
|
|
||||||
// button doubles as a drift-recovery. The button lives on the status page: it
|
|
||||||
// is a "put the daemons back in the state the database describes" action,
|
|
||||||
// which belongs with the rest of the server-health screen rather than in the
|
|
||||||
// domain list's top bar.
|
|
||||||
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := s.domains.Resync(); err != nil {
|
|
||||||
logf("panel: manual reload (opendkim): %v", err)
|
|
||||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.apps.Resync(); err != nil {
|
|
||||||
logf("panel: manual reload (postfix): %v", err)
|
|
||||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
|
|
||||||
// bad id or a missing domain and reporting ok=false in that case.
|
|
||||||
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
|
||||||
id, ok := parseDomainID(w, r)
|
|
||||||
if !ok {
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
d, err := s.domains.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainNotFound) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
logf("panel: get domain %d: %v", id, err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
return d, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/legal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLicenseHandlerServesAGPL(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/license", nil)
|
||||||
|
handleLicense(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
ct := rec.Header().Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, "text/plain") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
|
||||||
|
t.Error("response is missing the AGPL title")
|
||||||
|
}
|
||||||
|
if body != string(legal.License) {
|
||||||
|
t.Error("response body does not match legal.License")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseHandlerRejectsNonGET(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/license", nil)
|
||||||
|
handleLicense(rec, req)
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("status = %d, want 405", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleSetup serves the one-time administrator creation flow at
|
|
||||||
// /setup/<token> (security.md). Once an administrator exists the whole route
|
|
||||||
// returns 404; an invalid or expired token is indistinguishable from a missing
|
|
||||||
// page, also 404.
|
|
||||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Route-specific rate limit, separate from login (security.md).
|
|
||||||
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
|
||||||
// Reject nested/garbage paths outright.
|
|
||||||
if token == "" || strings.Contains(token, "/") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.setup.validate(token) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderSetupForm(w, http.StatusOK, token, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitSetup(w, r, token)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
|
||||||
s.render(w, status, "setup", map[string]any{
|
|
||||||
"Title": "SelfPost — Create administrator",
|
|
||||||
"Active": "setup",
|
|
||||||
"Token": token,
|
|
||||||
"Error": formErr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
password := r.PostFormValue("password")
|
|
||||||
confirm := r.PostFormValue("password_confirm")
|
|
||||||
|
|
||||||
if err := validateUsername(username); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if password != confirm {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateAdminPassword(password); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: setup: hashing password failed: %v", err)
|
|
||||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
|
|
||||||
// A concurrent submission may have already created the admin; the
|
|
||||||
// id=1 / non-empty-table guard makes this the second writer. Treat it
|
|
||||||
// as "setup already done" rather than an error.
|
|
||||||
if exists, _ := s.store.AdminExists(); exists {
|
|
||||||
s.setup.complete()
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: setup: create admin failed: %v", err)
|
|
||||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup is now permanently complete: burn the token (security.md).
|
|
||||||
s.setup.complete()
|
|
||||||
logf("panel: administrator %q created; setup link is now disabled", username)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleStatus renders the server status page: the panel's landing page and the
|
|
||||||
// one screen that answers "is the service healthy and will mail be accepted".
|
|
||||||
// The cheap local checks live in the polled "status_body"
|
|
||||||
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
|
|
||||||
// because neither belongs on a five-second timer.
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := s.statusBody()
|
|
||||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
|
||||||
|
|
||||||
data["Title"] = "SelfPost — status"
|
|
||||||
data["User"] = currentUser(r)
|
|
||||||
data["Active"] = "status"
|
|
||||||
data["Flash"] = statusFlash(r)
|
|
||||||
data["Hostname"] = s.cfg.Hostname
|
|
||||||
data["PTR"] = srv.PTR
|
|
||||||
s.render(w, http.StatusOK, "status", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatusFragment serves the HTMX polling fragment for the local checks
|
|
||||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
|
||||||
// JSON).
|
|
||||||
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
|
|
||||||
// and returns to the page. DNS is the one part of this screen that talks to the
|
|
||||||
// network, so it refreshes on demand rather than with the poll.
|
|
||||||
func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.dns.Server(s.cfg.Hostname, true)
|
|
||||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusBody collects the local checks the fragment renders. Each one
|
|
||||||
// reports its own problem rather than failing the page, so a broken component
|
|
||||||
// costs one line and not the whole screen.
|
|
||||||
func (s *Server) statusBody() map[string]any {
|
|
||||||
procs, procErr := health.Processes()
|
|
||||||
procStatus := health.StatusUnknown
|
|
||||||
if procErr != nil {
|
|
||||||
// Outside the container (or if the control socket is gone) there is
|
|
||||||
// nothing to report — "unknown", not "everything is broken".
|
|
||||||
logf("panel: status: supervisorctl: %v", procErr)
|
|
||||||
} else {
|
|
||||||
for _, p := range procs {
|
|
||||||
procStatus = health.Worst(procStatus, p.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queueText, queueErr := readQueue()
|
|
||||||
queueStatus := health.StatusOK
|
|
||||||
if queueErr != "" {
|
|
||||||
queueStatus = health.StatusWarn
|
|
||||||
}
|
|
||||||
|
|
||||||
cert := health.CheckCertificate(s.cfg.TLSCertFile)
|
|
||||||
sockets := []health.Socket{
|
|
||||||
// OpenDKIM signs every outgoing message and Postfix is configured to
|
|
||||||
// tempfail without it: a missing socket stops mail.
|
|
||||||
health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
|
|
||||||
// The journal-milter only records the send log and fails open.
|
|
||||||
health.CheckSocket("send-log", s.cfg.JournalSocket, false),
|
|
||||||
}
|
|
||||||
socketStatus := health.StatusUnknown
|
|
||||||
for _, sock := range sockets {
|
|
||||||
socketStatus = health.Worst(socketStatus, sock.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource usage of the machine underneath. It is graded like the rest —
|
|
||||||
// a processor that is fully busy or a machine out of memory delays or
|
|
||||||
// kills the mail path — so it counts towards the headline verdict, and
|
|
||||||
// its rates are measured against the previous poll (internal/health).
|
|
||||||
machine := s.machine.Sample()
|
|
||||||
|
|
||||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
|
||||||
return map[string]any{
|
|
||||||
"Processes": procs,
|
|
||||||
"ProcessError": procErr != nil,
|
|
||||||
"ProcessStatus": procStatus,
|
|
||||||
"QueueSummary": queueSummary(queueText),
|
|
||||||
"QueueError": queueErr,
|
|
||||||
"QueueStatus": queueStatus,
|
|
||||||
"Machine": machine,
|
|
||||||
"Cert": cert,
|
|
||||||
"Sockets": sockets,
|
|
||||||
"SocketStatus": socketStatus,
|
|
||||||
"OverallStatus": overall,
|
|
||||||
"OverallHeading": overallHeading(overall),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueSummary reduces postqueue's listing to the one line worth showing on
|
|
||||||
// the status page; the full listing has its own screen (architecture.md §
|
|
||||||
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
|
|
||||||
// trailing "-- N Kbytes in M Requests."
|
|
||||||
func queueSummary(out string) string {
|
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
||||||
for i := len(lines) - 1; i >= 0; i-- {
|
|
||||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// overallHeading turns the worst check into the page's one-line verdict.
|
|
||||||
func overallHeading(worst health.Status) string {
|
|
||||||
switch worst {
|
|
||||||
case health.StatusError:
|
|
||||||
return "A component needs attention — see the details below."
|
|
||||||
case health.StatusWarn:
|
|
||||||
return "Running, with warnings below."
|
|
||||||
case health.StatusOK:
|
|
||||||
return "All components are running normally."
|
|
||||||
default:
|
|
||||||
return "Some checks could not be performed."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func statusFlash(r *http.Request) string {
|
|
||||||
switch {
|
|
||||||
case r.URL.Query().Get("reloaded") != "":
|
|
||||||
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
|
|
||||||
case r.URL.Query().Get("rechecked") != "":
|
|
||||||
return "DNS re-checked."
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const usernameKey ctxKey = 0
|
|
||||||
|
|
||||||
// requireAuth wraps a handler so only requests with a valid session cookie
|
|
||||||
// reach it; everyone else is redirected to the login page. The authenticated
|
|
||||||
// username is stashed in the request context for downstream handlers.
|
|
||||||
//
|
|
||||||
// It also extends the sliding session (plan B.1) on activity, defined as
|
|
||||||
// everything except a GET request carrying HX-Request: the four monitoring
|
|
||||||
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
|
|
||||||
// /deliveries/rows)
|
|
||||||
// poll on a timer regardless of whether anyone is looking at the tab, so
|
|
||||||
// counting those as activity would make "N days idle" mean "N days since a
|
|
||||||
// browser tab was last open" instead.
|
|
||||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token, ok := s.sessionToken(r)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username, ok := s.sessions.Lookup(token)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isSessionActivity(r) && s.sessions.Touch(token) {
|
|
||||||
s.setSessionCookie(w, token)
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSessionActivity reports whether a request counts as administrator
|
|
||||||
// activity for the sliding session timeout, per requireAuth's doc comment.
|
|
||||||
func isSessionActivity(r *http.Request) bool {
|
|
||||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// currentUser returns the authenticated username from the request context.
|
|
||||||
func currentUser(r *http.Request) string {
|
|
||||||
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestSessionStore(t *testing.T) *sessionStore {
|
|
||||||
t.Helper()
|
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open store: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { st.Close() })
|
|
||||||
return newSessionStore(st, 7*24*time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionRename(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
s.Rename(token, "operator")
|
|
||||||
|
|
||||||
name, ok := s.Lookup(token)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("session lost after rename")
|
|
||||||
}
|
|
||||||
if name != "operator" {
|
|
||||||
t.Fatalf("session username = %q, want %q", name, "operator")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A password change must invalidate every other session (so a cookie captured
|
|
||||||
// under the old password stops working) while keeping the one performing the
|
|
||||||
// change signed in.
|
|
||||||
func TestSessionDestroyOthers(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
keep := s.Create("admin")
|
|
||||||
other := s.Create("admin")
|
|
||||||
|
|
||||||
s.DestroyOthers(keep)
|
|
||||||
|
|
||||||
if _, ok := s.Lookup(keep); !ok {
|
|
||||||
t.Fatal("current session was destroyed")
|
|
||||||
}
|
|
||||||
if _, ok := s.Lookup(other); ok {
|
|
||||||
t.Fatal("other session survived")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A session past its sliding idle expiry must not be honoured.
|
|
||||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
s.idle = -time.Minute // already expired the instant it's created
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
if _, ok := s.Lookup(token); ok {
|
|
||||||
t.Fatal("expired session was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch must not rewrite the expiry (or report a renewal) inside the
|
|
||||||
// once-an-hour throttle window, so an active tab's polling doesn't turn into
|
|
||||||
// a database write per request.
|
|
||||||
func TestSessionTouchThrottled(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
if s.Touch(token) {
|
|
||||||
t.Fatal("touch renewed a session created moments ago")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back-date the session's last renewal by rewriting its expiry, as if it
|
|
||||||
// had been created (or last renewed) 2 hours ago rather than moments ago.
|
|
||||||
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
|
||||||
t.Fatalf("renew session: %v", err)
|
|
||||||
}
|
|
||||||
if !s.Touch(token) {
|
|
||||||
t.Fatal("touch did not renew a session past the throttle window")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Account</h1>
|
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
<p class="muted">Leave both new-password fields empty to change the username
|
|
||||||
only. Changing the password signs out every other session; this one stays
|
|
||||||
signed in.</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// minAdminPasswordLen is the floor for the administrator password. The panel is
|
|
||||||
// public (security.md), so this is deliberately not tiny.
|
|
||||||
const minAdminPasswordLen = 12
|
|
||||||
|
|
||||||
const (
|
|
||||||
minUsernameLen = 3
|
|
||||||
maxUsernameLen = 64
|
|
||||||
)
|
|
||||||
|
|
||||||
// minSecretFilePasswordLen is the floor for the password protecting an
|
|
||||||
// encrypted backup or domain export. Such a file is offline and can be attacked
|
|
||||||
// at leisure, so the floor matches the administrator password's rather than the
|
|
||||||
// weaker "any password is better than none".
|
|
||||||
const minSecretFilePasswordLen = minAdminPasswordLen
|
|
||||||
|
|
||||||
// validateUsername enforces a strict server-side whitelist (security.md):
|
|
||||||
// letters, digits, dot, dash, underscore. Client validation is never trusted.
|
|
||||||
func validateUsername(u string) error {
|
|
||||||
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
|
||||||
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
|
||||||
}
|
|
||||||
for _, r := range u {
|
|
||||||
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
|
||||||
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAdminPassword enforces a minimum length. Composition rules beyond
|
|
||||||
// length tend to reduce entropy in practice, so length is the sole gate.
|
|
||||||
func validateAdminPassword(p string) error {
|
|
||||||
if len(p) < minAdminPasswordLen {
|
|
||||||
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isASCIILetterOrDigit(r rune) bool {
|
|
||||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
|
||||||
|
|
||||||
// normalizeDomain lower-cases and trims a domain name. Domain names are
|
|
||||||
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
|
||||||
// lower-case form, so we normalise before both validation and storage.
|
|
||||||
func normalizeDomain(name string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateDomain enforces a strict server-side whitelist for sending-domain
|
|
||||||
// names (security.md). The result is safe to write verbatim into the OpenDKIM
|
|
||||||
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
|
||||||
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
|
||||||
// shape. Input must already be normalised with normalizeDomain.
|
|
||||||
//
|
|
||||||
// This is deliberately stricter than "any string DNS might accept" — no
|
|
||||||
// leading/trailing dots or hyphens, no empty or over-long labels, and at least
|
|
||||||
// two labels so single-word hostnames cannot be registered as sending domains.
|
|
||||||
func validateDomain(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("domain is required")
|
|
||||||
}
|
|
||||||
if len(name) > maxDomainLen {
|
|
||||||
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
|
|
||||||
}
|
|
||||||
labels := strings.Split(name, ".")
|
|
||||||
if len(labels) < 2 {
|
|
||||||
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
|
||||||
}
|
|
||||||
for _, label := range labels {
|
|
||||||
if err := validateDomainLabel(label); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDomainLabel(label string) error {
|
|
||||||
if len(label) == 0 {
|
|
||||||
return fmt.Errorf("domain must not contain an empty label")
|
|
||||||
}
|
|
||||||
if len(label) > 63 {
|
|
||||||
return fmt.Errorf("each domain label must be at most 63 characters")
|
|
||||||
}
|
|
||||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
|
||||||
return fmt.Errorf("domain labels must not start or end with '-'")
|
|
||||||
}
|
|
||||||
for i := 0; i < len(label); i++ {
|
|
||||||
c := label[i]
|
|
||||||
lower := c >= 'a' && c <= 'z'
|
|
||||||
digit := c >= '0' && c <= '9'
|
|
||||||
if !lower && !digit && c != '-' {
|
|
||||||
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package validate holds shared server-side form validation for the panel.
|
||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinAdminPasswordLen is the floor for the administrator password. The panel is
|
||||||
|
// public (security.md), so this is deliberately not tiny.
|
||||||
|
const MinAdminPasswordLen = 12
|
||||||
|
|
||||||
|
const (
|
||||||
|
minUsernameLen = 3
|
||||||
|
maxUsernameLen = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinSecretFilePasswordLen is the floor for the password protecting an
|
||||||
|
// encrypted backup or domain export. Such a file is offline and can be attacked
|
||||||
|
// at leisure, so the floor matches the administrator password's rather than the
|
||||||
|
// weaker "any password is better than none".
|
||||||
|
const MinSecretFilePasswordLen = MinAdminPasswordLen
|
||||||
|
|
||||||
|
// Username enforces a strict server-side whitelist (security.md): letters,
|
||||||
|
// digits, dot, dash, underscore. Client validation is never trusted.
|
||||||
|
func Username(u string) error {
|
||||||
|
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
||||||
|
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
||||||
|
}
|
||||||
|
for _, r := range u {
|
||||||
|
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
||||||
|
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPassword enforces a minimum length. Composition rules beyond length tend
|
||||||
|
// to reduce entropy in practice, so length is the sole gate.
|
||||||
|
func AdminPassword(p string) error {
|
||||||
|
if len(p) < MinAdminPasswordLen {
|
||||||
|
return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIILetterOrDigit(r rune) bool {
|
||||||
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
||||||
|
|
||||||
|
// NormalizeDomain lower-cases and trims a domain name. Domain names are
|
||||||
|
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
||||||
|
// lower-case form, so we normalise before both validation and storage.
|
||||||
|
func NormalizeDomain(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain enforces a strict server-side whitelist for sending-domain names
|
||||||
|
// (security.md). The result is safe to write verbatim into the OpenDKIM
|
||||||
|
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
||||||
|
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
||||||
|
// shape. Input must already be normalised with NormalizeDomain.
|
||||||
|
func Domain(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("domain is required")
|
||||||
|
}
|
||||||
|
if len(name) > maxDomainLen {
|
||||||
|
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
|
||||||
|
}
|
||||||
|
labels := strings.Split(name, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
||||||
|
}
|
||||||
|
for _, label := range labels {
|
||||||
|
if err := domainLabel(label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainLabel(label string) error {
|
||||||
|
if len(label) == 0 {
|
||||||
|
return fmt.Errorf("domain must not contain an empty label")
|
||||||
|
}
|
||||||
|
if len(label) > 63 {
|
||||||
|
return fmt.Errorf("each domain label must be at most 63 characters")
|
||||||
|
}
|
||||||
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return fmt.Errorf("domain labels must not start or end with '-'")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(label); i++ {
|
||||||
|
c := label[i]
|
||||||
|
lower := c >= 'a' && c <= 'z'
|
||||||
|
digit := c >= '0' && c <= '9'
|
||||||
|
if !lower && !digit && c != '-' {
|
||||||
|
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freemailDomains lists public mail hosts that cannot publish _report._dmarc
|
||||||
|
// authorisation for third-party sending domains.
|
||||||
|
var freemailDomains = map[string]struct{}{
|
||||||
|
"gmail.com": {},
|
||||||
|
"googlemail.com": {},
|
||||||
|
"outlook.com": {},
|
||||||
|
"hotmail.com": {},
|
||||||
|
"live.com": {},
|
||||||
|
"yahoo.com": {},
|
||||||
|
"icloud.com": {},
|
||||||
|
"me.com": {},
|
||||||
|
"proton.me": {},
|
||||||
|
"protonmail.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email checks a DMARC rua= mailbox. Empty is allowed (policy-only).
|
||||||
|
func Email(addr string) error {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at <= 0 || at >= len(addr)-1 {
|
||||||
|
return fmt.Errorf("enter a valid email address")
|
||||||
|
}
|
||||||
|
local := addr[:at]
|
||||||
|
domain := NormalizeDomain(addr[at+1:])
|
||||||
|
if err := Domain(domain); err != nil {
|
||||||
|
return fmt.Errorf("email domain is invalid: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range local {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return fmt.Errorf("email address must be ASCII")
|
||||||
|
}
|
||||||
|
if !isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_' && r != '+' {
|
||||||
|
return fmt.Errorf("email address contains invalid characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, blocked := freemailDomains[domain]; blocked {
|
||||||
|
return fmt.Errorf("use an address on a domain you control; public mail hosts cannot receive authorised DMARC reports")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEmail(t *testing.T) {
|
||||||
|
if err := Email(""); err != nil {
|
||||||
|
t.Errorf("empty: %v", err)
|
||||||
|
}
|
||||||
|
if err := Email("reports@mail.example.com"); err != nil {
|
||||||
|
t.Errorf("valid: %v", err)
|
||||||
|
}
|
||||||
|
if err := Email("bad"); err == nil {
|
||||||
|
t.Error("bad address accepted")
|
||||||
|
}
|
||||||
|
if err := Email("x@gmail.com"); err == nil {
|
||||||
|
t.Error("gmail accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeDomain(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
" Example.COM ": "example.com",
|
||||||
|
"MAIL.Example.Org": "mail.example.org",
|
||||||
|
"example.com": "example.com",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeDomain(in); got != want {
|
||||||
|
t.Errorf("NormalizeDomain(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainValid(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"example.com",
|
||||||
|
"mail.example.com",
|
||||||
|
"a.co",
|
||||||
|
"sub-domain.example.co.uk",
|
||||||
|
"x1.y2.z3",
|
||||||
|
"1example.com",
|
||||||
|
}
|
||||||
|
for _, d := range valid {
|
||||||
|
if err := Domain(d); err != nil {
|
||||||
|
t.Errorf("Domain(%q) unexpected error: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainInvalid(t *testing.T) {
|
||||||
|
invalid := []string{
|
||||||
|
"",
|
||||||
|
"localhost",
|
||||||
|
"example",
|
||||||
|
".example.com",
|
||||||
|
"example.com.",
|
||||||
|
"exa mple.com",
|
||||||
|
"example..com",
|
||||||
|
"-example.com",
|
||||||
|
"example-.com",
|
||||||
|
"example.com\n",
|
||||||
|
"exa*mple.com",
|
||||||
|
"exa_mple.com",
|
||||||
|
"Example.com",
|
||||||
|
"пример.рф",
|
||||||
|
"example.c/m",
|
||||||
|
}
|
||||||
|
for _, d := range invalid {
|
||||||
|
if err := Domain(d); err == nil {
|
||||||
|
t.Errorf("Domain(%q) = nil, want error", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||||
|
label := make([]byte, 64)
|
||||||
|
for i := range label {
|
||||||
|
label[i] = 'a'
|
||||||
|
}
|
||||||
|
if err := Domain(string(label) + ".com"); err == nil {
|
||||||
|
t.Error("expected error for over-long label")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestNormalizeDomain(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
" Example.COM ": "example.com",
|
|
||||||
"MAIL.Example.Org": "mail.example.org",
|
|
||||||
"example.com": "example.com",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := normalizeDomain(in); got != want {
|
|
||||||
t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainValid(t *testing.T) {
|
|
||||||
valid := []string{
|
|
||||||
"example.com",
|
|
||||||
"mail.example.com",
|
|
||||||
"a.co",
|
|
||||||
"sub-domain.example.co.uk",
|
|
||||||
"x1.y2.z3",
|
|
||||||
"1example.com",
|
|
||||||
}
|
|
||||||
for _, d := range valid {
|
|
||||||
if err := validateDomain(d); err != nil {
|
|
||||||
t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainInvalid(t *testing.T) {
|
|
||||||
invalid := []string{
|
|
||||||
"", // empty
|
|
||||||
"localhost", // single label
|
|
||||||
"example", // single label
|
|
||||||
".example.com", // leading dot -> empty label
|
|
||||||
"example.com.", // trailing dot -> empty label
|
|
||||||
"exa mple.com", // space
|
|
||||||
"example..com", // empty label
|
|
||||||
"-example.com", // label starts with '-'
|
|
||||||
"example-.com", // label ends with '-'
|
|
||||||
"example.com\n", // newline (config injection attempt)
|
|
||||||
"exa*mple.com", // disallowed char
|
|
||||||
"exa_mple.com", // underscore not allowed in domains
|
|
||||||
"Example.com", // upper-case (must be normalised first)
|
|
||||||
"пример.рф", // non-ASCII
|
|
||||||
"example.c/m", // slash (path-traversal attempt)
|
|
||||||
}
|
|
||||||
for _, d := range invalid {
|
|
||||||
if err := validateDomain(d); err == nil {
|
|
||||||
t.Errorf("validateDomain(%q) = nil, want error", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainLongLabelRejected(t *testing.T) {
|
|
||||||
label := make([]byte, 64)
|
|
||||||
for i := range label {
|
|
||||||
label[i] = 'a'
|
|
||||||
}
|
|
||||||
if err := validateDomain(string(label) + ".com"); err == nil {
|
|
||||||
t.Error("expected error for over-long label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticETags maps each /static/ URL path to a strong ETag over the asset's
|
||||||
|
// bytes, computed once at startup from the embedded FS.
|
||||||
|
//
|
||||||
|
// The assets are baked into the binary, so their FS modification times are the
|
||||||
|
// zero value and http.FileServer sends no Last-Modified. Without an ETag either,
|
||||||
|
// a response carries no validator at all and the browser is free to guess how
|
||||||
|
// long to keep it — which is how a replaced favicon keeps showing the old mark
|
||||||
|
// long after a deploy. Hashing the content gives every asset a validator that
|
||||||
|
// changes exactly when the asset does.
|
||||||
|
var staticETags = buildStaticETags()
|
||||||
|
|
||||||
|
func buildStaticETags() map[string]string {
|
||||||
|
etags := make(map[string]string)
|
||||||
|
// An error here would mean the embed directive and this walk disagree, which
|
||||||
|
// is a build-time defect rather than a runtime condition; the assets still
|
||||||
|
// serve correctly without a validator, so skip what can't be read.
|
||||||
|
_ = fs.WalkDir(assetsFS, "static", func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(assetsFS, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
etags["/"+name] = `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return etags
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticHandler serves the embedded assets under /static/ with a content ETag.
|
||||||
|
//
|
||||||
|
// Cache-Control is no-cache rather than a max-age: it lets the browser keep the
|
||||||
|
// copy but requires it to revalidate, so an asset that changed is picked up on
|
||||||
|
// the next page load while an unchanged one costs a 304 with no body. For a
|
||||||
|
// handful of small files on a single-operator panel that trade is the right way
|
||||||
|
// round — correctness after a deploy matters more than saving the round trip.
|
||||||
|
// http.ServeContent answers the conditional request from the ETag we set here.
|
||||||
|
func StaticHandler() http.Handler {
|
||||||
|
files := http.FileServer(http.FS(assetsFS))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok {
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -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,15 +152,19 @@ 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; }
|
||||||
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 {
|
input {
|
||||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; 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;
|
||||||
}
|
}
|
||||||
/* 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
|
||||||
@@ -123,25 +178,35 @@ 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; }
|
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||||
.muted { color: #6b7280; }
|
.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
|
||||||
form's block layout; its button is styled like any other. */
|
form's block layout; its button is styled like any other. */
|
||||||
form.inline { display: inline; margin: 0; }
|
form.inline { display: inline; margin: 0; }
|
||||||
/* The signed-out pages are a single card and nothing else, and .card.narrow
|
/* A page whose card is the narrow one takes the column down with it. .card.narrow
|
||||||
centres itself inside whatever holds it — so at the panel's usual width the
|
centres itself inside whatever holds it, so at the panel's usual width the
|
||||||
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 lines the three up and puts the block as a whole in the
|
||||||
the middle of the page. */
|
middle of the page.
|
||||||
main.page-login, main.page-setup { max-width: 24rem; }
|
Named by what the page holds rather than by which page it is: it was written
|
||||||
|
for login and setup, and Settings and the user form have the same single
|
||||||
|
narrow card and had the same split heading — a list of page names would have
|
||||||
|
had to be remembered and extended by every page added since. */
|
||||||
|
main:has(> .card.narrow) { 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 +214,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 the
|
||||||
|
other two small all-caps labels in the panel (.sections-title, .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 +244,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,15 +258,20 @@ 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; }
|
.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:hover { color: var(--fg); }
|
||||||
|
/* The narrow column is 24rem; a right-aligned multi-link notice wraps into a
|
||||||
|
ragged edge, so centre it there. */
|
||||||
|
main:has(> .card.narrow) .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. */
|
||||||
@@ -199,7 +286,7 @@ 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
|
||||||
@@ -220,7 +307,7 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
.app .actions > .t-mode:checked ~ .for-mode,
|
.app .actions > .t-mode:checked ~ .for-mode,
|
||||||
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
||||||
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
||||||
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid #2563eb; outline-offset: 2px; }
|
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid var(--accent-fill); 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:
|
||||||
@@ -288,16 +375,21 @@ 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 padding
|
||||||
and colours come from the compact button rule further down. */
|
and colours come from the compact button rule further down. */
|
||||||
@@ -316,14 +408,15 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
}
|
}
|
||||||
.sections-title {
|
.sections-title {
|
||||||
margin: 0 0 0.25rem 0.6rem;
|
margin: 0 0 0.25rem 0.6rem;
|
||||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
font-family: var(--font-mono);
|
||||||
color: #6b7280;
|
font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
/* Quieter and a step in from the page entries: this is an index of one page,
|
/* Quieter and a step in from the page entries: this is an index of one page,
|
||||||
subordinate to the list of pages above it. */
|
subordinate to the list of pages above it. */
|
||||||
.nav .sections a {
|
.nav .sections a {
|
||||||
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
||||||
font-size: 0.85rem; color: #6b7280; text-decoration: none;
|
font-size: 0.85rem; color: var(--muted); text-decoration: none;
|
||||||
}
|
}
|
||||||
.nav .sections a:hover { color: var(--fg); }
|
.nav .sections a:hover { color: var(--fg); }
|
||||||
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
||||||
@@ -362,9 +455,14 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
|||||||
/* 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. */
|
||||||
.st {
|
.st {
|
||||||
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
|
display: inline-block; padding: 0.1rem 0.45rem; 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; 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); }
|
||||||
@@ -423,8 +521,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
|
||||||
@@ -432,16 +530,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
|
||||||
@@ -465,7 +563,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
|
||||||
@@ -481,7 +579,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
|
||||||
@@ -492,8 +590,8 @@ 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); }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveStatic runs one GET against the static handler.
|
||||||
|
func serveStatic(path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
for k, v := range headers {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
StaticHandler().ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every embedded asset must carry a validator. The favicon is the one that
|
||||||
|
// 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.
|
||||||
|
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||||
|
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)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("ETag") == "" {
|
||||||
|
t.Errorf("%s: no ETag", name)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
|
||||||
|
t.Errorf("%s: Cache-Control = %q, want %q", name, got, "no-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of the ETag is the cheap second request: the browser sends back
|
||||||
|
// what it has and gets a bodyless 304 when nothing changed.
|
||||||
|
func TestStaticETagRevalidates(t *testing.T) {
|
||||||
|
first := serveStatic("/static/favicon.png", nil)
|
||||||
|
etag := first.Header().Get("ETag")
|
||||||
|
if etag == "" {
|
||||||
|
t.Fatal("no ETag on the first response")
|
||||||
|
}
|
||||||
|
|
||||||
|
same := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": etag})
|
||||||
|
if same.Code != http.StatusNotModified {
|
||||||
|
t.Errorf("matching If-None-Match: got status %d, want 304", same.Code)
|
||||||
|
}
|
||||||
|
if same.Body.Len() != 0 {
|
||||||
|
t.Errorf("matching If-None-Match: got %d bytes of body, want none", same.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale validator — what a browser holds after the asset is replaced —
|
||||||
|
// has to produce the new bytes rather than another 304.
|
||||||
|
stale := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": `"0000000000000000"`})
|
||||||
|
if stale.Code != http.StatusOK {
|
||||||
|
t.Errorf("stale If-None-Match: got status %d, want 200", stale.Code)
|
||||||
|
}
|
||||||
|
if stale.Body.Len() == 0 {
|
||||||
|
t.Error("stale If-None-Match: empty body, want the asset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two different assets must not share a validator, or replacing one would be
|
||||||
|
// masked by the other's cached copy.
|
||||||
|
func TestStaticETagsAreContentDerived(t *testing.T) {
|
||||||
|
png := serveStatic("/static/favicon.png", nil).Header().Get("ETag")
|
||||||
|
svg := serveStatic("/static/favicon.svg", nil).Header().Get("ETag")
|
||||||
|
if png == svg {
|
||||||
|
t.Errorf("favicon.png and favicon.svg share the ETag %s", png)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
|
||||||
|
{{if .ShowDMARC}}
|
||||||
|
<h3>DMARC aggregate reports</h3>
|
||||||
|
<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
|
||||||
|
receives inbound mail. SelfPost is send-only today; a future release will
|
||||||
|
be able to receive reports in the panel itself.</p>
|
||||||
|
|
||||||
|
<label for="dmarc_report_email">Default report address</label>
|
||||||
|
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||||
|
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
|
||||||
|
|
||||||
|
{{if .FormDMARCEmail}}
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||||
|
domain must publish a report-authorisation record so receivers will deliver
|
||||||
|
the XML aggregates.</p>
|
||||||
|
|
||||||
|
<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">{{.ReportAuthExample}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .ReportAuthDNS.Status}}
|
||||||
|
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
|
||||||
|
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
|
||||||
|
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<button type="submit">Save changes</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username
|
||||||
|
{{if .ShowDMARC}}or DMARC address {{end}}only. Changing the password signs out every other session;
|
||||||
|
this one stays signed in.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{{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">
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
<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>
|
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">{{.Status}}</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}}
|
||||||
@@ -51,8 +51,10 @@
|
|||||||
<h2>SPF and DMARC records</h2>
|
<h2>SPF and DMARC records</h2>
|
||||||
<p class="muted">These two are not generated the way the DKIM record above is —
|
<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
|
they are policy, and the domain may already publish an SPF record for other
|
||||||
senders. What follows is what this server expects to see and what the checks
|
senders. SelfPost is a send-only relay — most operators have no inbox on the
|
||||||
below look for. Publish both as TXT records.</p>
|
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>
|
<label>SPF — host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
@@ -68,9 +70,23 @@
|
|||||||
|
|
||||||
<p class="muted">A domain may have only one SPF record. If it already has one,
|
<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
|
do not add a second — add this server's mechanism to the existing record
|
||||||
instead, before its <code>all</code> term. <code>-all</code> at the end means
|
instead, before its <code>all</code> term.</p>
|
||||||
nothing else may send as the domain; use <code>~all</code> while other senders
|
|
||||||
are still being sorted out.</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>
|
<label>DMARC — host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
@@ -78,17 +94,36 @@
|
|||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>DMARC — value</label>
|
<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">
|
<div class="code-row">
|
||||||
<span class="code">{{.DMARCExample}}</span>
|
<span class="code">{{.DMARCExample}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
|
{{if .SameDomainRUA}}
|
||||||
the aggregate reports are how you find out who else sends as the domain.
|
<p class="error">The report address is on this sending domain. SelfPost does
|
||||||
<code>p=none</code> changes nothing about delivery, so it is safe to publish
|
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||||
straight away; tighten it to <code>p=quarantine</code> and then
|
reception in a future release.</p>
|
||||||
<code>p=reject</code> once the reports come back clean.</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>
|
||||||
|
|
||||||
<div class="card" id="dns-status">
|
<div class="card" id="dns-status">
|
||||||
@@ -116,6 +151,13 @@
|
|||||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||||
{{end}}</span>{{end}}
|
{{end}}</span>{{end}}
|
||||||
|
|
||||||
|
{{if .DNS.DMARCReportAuth.Status}}
|
||||||
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||||
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||||
|
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<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>
|
||||||
@@ -37,11 +37,17 @@
|
|||||||
tell the send log from a single delivery's page. */}}
|
tell the send log from a single delivery's page. */}}
|
||||||
<main class="page-{{.Active}} {{template "wide" .}}">
|
<main class="page-{{.Active}} {{template "wide" .}}">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
{{/* The running version, on every authenticated page: it is what a backup
|
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
|
||||||
manifest is checked against on restore and the first thing to establish
|
where the Corresponding Source is, and that there is no warranty. Shown
|
||||||
when something behaves unexpectedly. Only for signed-in administrators —
|
on every page, including login/setup — those are interactive UIs too.
|
||||||
the login and setup pages must not advertise it to the internet. */}}
|
The running version stays signed-in only: it is what a backup manifest is
|
||||||
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
|
checked against on restore, and must not be advertised to the internet. */}}
|
||||||
|
<footer class="version">
|
||||||
|
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
|
||||||
|
<a href="/license">License (AGPL-3.0)</a> ·
|
||||||
|
<a href="{{.SourceURL}}">Source</a> ·
|
||||||
|
No warranty
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -58,19 +64,24 @@
|
|||||||
the column: the mark, the panel's pages, the current page's own sections,
|
the column: the mark, the panel's pages, the current page's own sections,
|
||||||
and the session last. The mark goes to /status, the page the panel opens
|
and the session last. The mark goes to /status, the page the panel opens
|
||||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
on — named outright rather 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-account"}}Users</span>{{else}}<a href="/users">{{template "icon-account"}}Users</a>{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
{{template "sections" .}}
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">{{.User}}</span>
|
<span class="muted">User: {{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}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>
|
||||||