diff --git a/CHANGELOG.md b/CHANGELOG.md index 280644d..dc0d3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a38602b --- /dev/null +++ b/NOTICE @@ -0,0 +1,32 @@ +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/templates/layout.html) so it points at your sources. + +Third-party software included in this distribution +---------------------------------------------------- + +htmx 2.0.4 (internal/web/static/htmx.min.js) + Copyright (c) Big Sky Software + Licence: Zero-Clause BSD (0BSD) + https://github.com/bigskysoftware/htmx + +IBM Plex Sans / IBM Plex Mono (wordmarks in SVG logos only) + Copyright © 2017 IBM Corp., with Reserved Font Name "Plex" + Licence: SIL Open Font License 1.1 + The shipped SVGs use outlined paths derived from these fonts; the + font files themselves are not distributed. + +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. diff --git a/README.md b/README.md index 6b987df..7dae4b9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:1.0.1 + ghcr.io/mixeme/selfpost:1.1.0 ``` **Get the setup URL** (pick one): @@ -262,6 +262,9 @@ Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see ## License +Copyright © 2026 Mikhail Yenuchenko. + [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 available to its users — not only when you distribute copies of the code. +Third-party notices: [NOTICE](NOTICE). diff --git a/build/Dockerfile b/build/Dockerfile index 90fabda..0631979 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -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/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/logrotate-mail.conf /etc/logrotate.d/mail COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh diff --git a/cmd/panel/main.go b/cmd/panel/main.go index dfae2f1..6484cce 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -2,6 +2,9 @@ // several roles (architecture.md § Image and processes) as a supervised // process: the HTTP panel server, the journal-milter, the mail.log tailer and // the rate-limit checks. +// +// Copyright (C) 2026 Mikhail Yenuchenko +// SPDX-License-Identifier: AGPL-3.0-only package main import ( diff --git a/cmd/selfpost-backup/main.go b/cmd/selfpost-backup/main.go index 829bf9b..7231e40 100644 --- a/cmd/selfpost-backup/main.go +++ b/cmd/selfpost-backup/main.go @@ -16,6 +16,9 @@ // the same password: // // docker exec -i selfpost-backup -decrypt < backup.spbk > backup.tar.gz +// +// Copyright (C) 2026 Mikhail Yenuchenko +// SPDX-License-Identifier: AGPL-3.0-only package main import ( diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6f803df..d4dd7b6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.0.1 + image: ghcr.io/mixeme/selfpost:1.1.0 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/architecture.md b/docs/architecture.md index afa414f..aba6fe2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 absolute cap (regular use keeps the session alive indefinitely). - **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 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 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"] end 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 subgraph services ["Services — multi-store operations + rollback"] domainSvc["internal/domain"] diff --git a/docs/development.md b/docs/development.md index aca1af5..e8b51e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -92,8 +92,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review. ## External libraries -The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must -be permissive or GPL-family (see +The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and +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)). ### Main module (`go.mod`) @@ -107,6 +108,12 @@ be permissive or GPL-family (see Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in the tree are AGPL-3.0-compatible. +### Vendored front-end + +| Asset | Version | Repository | License | +|---|---|---|---| +| `internal/web/view/static/htmx.min.js` | 2.0.4 | | 0BSD | + ### E2e module (`test/e2e/go.mod`) | Package | Version | Repository | License | @@ -121,6 +128,8 @@ the image. Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come from Debian bookworm repositories; licenses are in each package's `copyright` file on . +The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under +`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`. --- @@ -132,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`. ```sh make build # bin/panel, bin/selfpost-backup (VERSION=dev by default) -make build VERSION=1.0.1 +make build VERSION=1.1.0 ``` Or directly: @@ -316,6 +325,7 @@ There is no `docs/archive/` directory. | [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 | +| [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/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) | | [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog | @@ -344,7 +354,7 @@ to prose. | Mail path | [build/postfix-config.sh](../build/postfix-config.sh) | | Panel routes | [internal/web/web.go](../internal/web/web.go) | | 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) | | 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) | diff --git a/docs/guide.md b/docs/guide.md index 81770e3..5378ee5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -148,8 +148,13 @@ Two different scopes — don't confuse them: behalf (e.g. `v=spf1 a mx ip4: -all`, adjusted to your setup). - **DKIM** — a TXT record with the exact value the panel shows on that domain's page (`domain page → DKIM TXT record`), one selector per domain. -- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts - building reporting/reputation history). +- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring + only, safe to publish immediately). On a send-only relay the sending domain + often has no inbox, so `rua=` is optional — configure a default report address + in *Settings* or per domain when you have a mailbox that receives inbound mail + elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that + hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …) + cannot be used as external report destinations. Skipping any of the three per-domain records is the single most common reason 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 (forward-confirmed reverse DNS), and each domain's page shows a *DNS status* card comparing the published DKIM record against the key this server signs with, -plus the domain's SPF 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 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 @@ -375,7 +381,7 @@ but it can look like an open port in external scans. ## Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. The current pin is `1.0.1`. 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 image was published. Pinning matters because of the backup version check above: the panel binary's embedded version and the image tag that produced it are the diff --git a/docs/plans/dmarc-reports.md b/docs/plans/dmarc-reports.md new file mode 100644 index 0000000..eda56a2 --- /dev/null +++ b/docs/plans/dmarc-reports.md @@ -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). diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md index 4384722..69c50e2 100644 --- a/docs/plans/domain-admin.md +++ b/docs/plans/domain-admin.md @@ -23,7 +23,7 @@ list: deletion, its own L2 limit); - the domain's DKIM/DNS status; - the send log filtered to the domain — the filter already exists in the log - ([sendLogData](../../internal/web/handlers_monitor.go)). + ([sendLogData](../../internal/web/handlers/handlers_monitor.go)). What stays outside the role is what is global by nature: diff --git a/docs/plans/web-split.md b/docs/plans/web-split.md index e2845a2..97c7b00 100644 --- a/docs/plans/web-split.md +++ b/docs/plans/web-split.md @@ -1,6 +1,6 @@ # Plan: web-split (splitting `internal/web`) -**Status:** agreed +**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`) **Version:** `1.x`; an internal refactor, it does not force a break on its own. --- @@ -42,11 +42,25 @@ domains. The refactor is cheaper before that growth than after 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 decision is made deliberately when the work starts — either the package is -split along the chosen scheme, or it is settled that it stays flat. After a -split: `build`/`vet`/`test` green, the panel's behaviour unchanged. +The package is split along the scheme above. After the split: `build`/`vet`/`test` +green, the panel's behaviour unchanged. ## Risks diff --git a/docs/roadmap.md b/docs/roadmap.md index 5212c78..ed9ada0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,15 +26,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Plan | |---|---|---|---| -| web-split | Splitting `internal/web` | **agreed** | [plans/web-split.md](plans/web-split.md) | | domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | contributing | `CONTRIBUTING.md` | candidate | — | +| visual-style | Обновление визуального стиля | candidate | — | +| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | -**Recommended order** (not binding): **web-split → domain-admin → -inbound-relay** — first the package split, then role-wide authorisation, then -the new vertical slice of the inbound relay. Deviating is allowed; there are no -hard phases here. +**Recommended order** (not binding): **domain-admin → +inbound-relay** — role-wide authorisation first, then the inbound relay vertical +slice. Deviating is allowed; there are no hard phases here. After a context reset, pick an item marked `agreed` or `in progress`, then work the checklist in its linked plan. @@ -57,8 +57,7 @@ engine stays outside the image, only the attachment point is provided. **Dependencies / risks:** a finished outbound path; open relay and backscatter; a wider attack surface (port 25 accepting mail). -**Order:** recommended after [web-split](plans/web-split.md) and -[domain-admin](plans/domain-admin.md). +**Order:** recommended after [domain-admin](plans/domain-admin.md). **Version:** target bump `1.x`; `2.x` possible — to be settled once the implementation lands. @@ -78,32 +77,13 @@ assigned domains (one or several). **Done when:** see [plans/domain-admin.md](plans/domain-admin.md). **Dependencies / risks:** a users table, the role in the session, authorisation -in every handler, setup and backup. **Order:** recommended after -[web-split](plans/web-split.md), before +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. --- -## web-split - -**Goal:** deliberately split `internal/web` (or settle on keeping the package -flat) before it grows under inbound-relay and domain-admin. - -**Boundary:** an internal refactor; the panel's behaviour for the operator does -not change. - -**Done when:** the package is split along the chosen scheme, or it is settled -that it stays flat — see [plans/web-split.md](plans/web-split.md). - -**Dependencies / risks:** exporting a package-private API. **Order:** -recommended **first** among the agreed features (before domain-admin and -inbound-relay). -**Version:** `1.x`; on its own it does not force a break. - ---- - ## contributing **Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run @@ -119,3 +99,52 @@ it. **Dependencies / risks:** with a single developer and no PRs, this is low priority. **Version:** no bearing on semver. + +--- + +## visual-style + +**Goal:** refresh the control panel's visual design — typography, colour tokens, +spacing, and component styling — without changing operator workflows or panel +behaviour. + +**Boundary:** presentation only (`panel.css`, templates, static assets); no new +features. Styling must stay compatible with the panel CSP — rules live in +`panel.css`, not inline (see [security.md](security.md) and the stylesheet +header). + +**Done when:** the panel reflects an agreed visual direction (starting +reference: [assets/selfpost-proof.html](assets/selfpost-proof.html)); light and +dark schemes remain supported; readability and contrast are preserved. + +**Dependencies / risks:** CSP constraints on how styles are applied; visual +regression across pages; low priority relative to functional work — take up +after explicit agreement, independently of the feature roadmap order. +**Version:** no bearing on semver. + +--- + +## dmarc-reports + +**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP, +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. + diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index ba33e4f..59a2181 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -56,12 +56,13 @@ type Server struct { // Domain is the published-DNS state of one sending domain. type Domain struct { - Name string - DKIM Result - SPF Result - DMARC Result - Overall health.Status - CheckedAt time.Time + Name string + DKIM Result + SPF Result + DMARC Result + DMARCReportAuth Result // zero when external rua= is not used + Overall health.Status + CheckedAt time.Time } // 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 // ServerIPs identify this server and come from a preceding Server check. type Query struct { - Name string - Selector string - ExpectedDKIM string - Hostname string - ServerIPs []string + Name string + Selector string + ExpectedDKIM string + Hostname 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 @@ -178,12 +180,19 @@ func (c *Checker) Forget(domainName string) { func (c *Checker) checkDomain(ctx context.Context, q Query) Domain { d := Domain{Name: q.Name, CheckedAt: time.Now()} 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.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() - 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 } diff --git a/internal/dnscheck/dnscheck_test.go b/internal/dnscheck/dnscheck_test.go index 11eb75c..9aae461 100644 --- a/internal/dnscheck/dnscheck_test.go +++ b/internal/dnscheck/dnscheck_test.go @@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) { txt["_dmarc.example.com"] = c.record } 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 { 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) { 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") { 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") } } + +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()) + } +} diff --git a/internal/dnscheck/examples.go b/internal/dnscheck/examples.go index 2eb3463..7c3c9be 100644 --- a/internal/dnscheck/examples.go +++ b/internal/dnscheck/examples.go @@ -1,6 +1,7 @@ package dnscheck import ( + "database/sql" "net" "strings" ) @@ -16,6 +17,13 @@ import ( // helper: it is published at the domain itself.) 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 // 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 @@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string { return "v=spf1 " + strings.Join(mechanisms, " ") + " -all" } -// DMARCExample is the least a domain should publish: monitoring only, with an -// address the aggregate reports go to. p=none is deliberate — it changes -// nothing about delivery, so it is safe to publish before the reports have -// shown that DKIM and SPF pass everywhere. -func DMARCExample(domainName string) string { - return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName +// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain +// override wins, then the administrator profile, then policy-only (empty). +func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string { + if domainRua.Valid { + return domainRua.String + } + 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 } diff --git a/internal/dnscheck/examples_test.go b/internal/dnscheck/examples_test.go index 2a542cf..8dae58b 100644 --- a/internal/dnscheck/examples_test.go +++ b/internal/dnscheck/examples_test.go @@ -2,6 +2,7 @@ package dnscheck import ( "context" + "database/sql" "strings" "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"}, {"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"}, - // 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"}, {"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 // same string, or the operator is told two different things on one page. 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) } - dmarc := c.checkDMARC(context.Background(), "example.com") + dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"}) if dmarc.Status != health.StatusWarn { 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) } } diff --git a/internal/dnscheck/records.go b/internal/dnscheck/records.go index 6d1c9ae..fe68255 100644 --- a/internal/dnscheck/records.go +++ b/internal/dnscheck/records.go @@ -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 // required for delivery, so its absence is advice (warn), not a fault. -func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result { - name := DMARCRecordName(domainName) +func (c *Checker) checkDMARC(ctx context.Context, q Query) Result { + name := DMARCRecordName(q.Name) txt, found, err := c.lookupTXT(ctx, name) if err != nil { return lookupFailed("the DMARC record", err) } + example := DMARCExample(q.DMARCReportEmail) + var records []string for _, rec := range txt { 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 { - return Result{ - Status: health.StatusWarn, - 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 := 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) + if q.DMARCReportEmail == "" { + 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 { 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} } +// 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 // whitespace removed: DNS providers and TXT chunking freely insert spaces and // line breaks into the base64, none of which are part of the key. diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 300ed55..206819e 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -1,6 +1,7 @@ package domain import ( + "database/sql" "fmt" "github.com/mixeme/selfpost/internal/buildinfo" @@ -23,6 +24,7 @@ type DomainExport struct { Domain string `json:"domain"` DKIMSelector string `json:"dkim_selector"` 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"` } @@ -59,6 +61,10 @@ func (s *Service) Export(id int64) (DomainExport, error) { DKIMPrivateKey: string(pem), Applications: make([]AppExport, 0, len(apps)), } + if d.DMARCRua.Valid { + s := d.DMARCRua.String + exp.DMARCRua = &s + } for _, a := range apps { password, err := s.apps.Secret(a.Login) if err != nil { @@ -110,6 +116,14 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) { 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 { if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil { s.importRollback(d.ID) diff --git a/internal/domain/transfer_test.go b/internal/domain/transfer_test.go index 98c8987..ddf08cf 100644 --- a/internal/domain/transfer_test.go +++ b/internal/domain/transfer_test.go @@ -1,6 +1,7 @@ package domain import ( + "database/sql" "errors" "fmt" "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 { 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) if err != nil { @@ -107,6 +111,12 @@ func TestExportImportRoundTrip(t *testing.T) { if got.Name != "example.com" || got.DKIMSelector != "selfpost" { 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. dstKey, err := dstOdk.ExportKey("example.com", "selfpost") if err != nil { diff --git a/internal/legal/LICENSE b/internal/legal/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/internal/legal/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/internal/legal/legal.go b/internal/legal/legal.go new file mode 100644 index 0000000..3eda2d9 --- /dev/null +++ b/internal/legal/legal.go @@ -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" diff --git a/internal/legal/license_test.go b/internal/legal/license_test.go new file mode 100644 index 0000000..d0f11e9 --- /dev/null +++ b/internal/legal/license_test.go @@ -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") + } +} diff --git a/internal/store/admin.go b/internal/store/admin.go index 31fcf77..7b7de03 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -12,9 +12,10 @@ 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 + Username string + PasswordHash string + DMARCReportEmail string // default rua= for all sending domains; empty = none + CreatedAt time.Time } // AdminExists reports whether the administrator account has been created. This @@ -47,10 +48,10 @@ func (s *Store) CreateAdmin(username, passwordHash string) error { // 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 { +func (s *Store) UpdateAdmin(username, passwordHash, dmarcReportEmail string) error { res, err := s.db.Exec( - "UPDATE admin SET username = ?, password_hash = ? WHERE id = 1", - username, passwordHash, + "UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1", + username, passwordHash, dmarcReportEmail, ) if err != nil { return fmt.Errorf("update admin: %w", err) @@ -71,8 +72,8 @@ func (s *Store) GetAdmin() (Admin, error) { 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) + err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1"). + Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt) if errors.Is(err, sql.ErrNoRows) { return Admin{}, ErrNoAdmin } diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go index c240f29..08056d9 100644 --- a/internal/store/admin_test.go +++ b/internal/store/admin_test.go @@ -11,7 +11,7 @@ func TestUpdateAdmin(t *testing.T) { if err := st.CreateAdmin("admin", "hash-one"); err != nil { t.Fatalf("CreateAdmin: %v", err) } - if err := st.UpdateAdmin("operator", "hash-two"); err != nil { + if err := st.UpdateAdmin("operator", "hash-two", "reports@hub.example"); err != nil { t.Fatalf("UpdateAdmin: %v", err) } @@ -19,7 +19,18 @@ func TestUpdateAdmin(t *testing.T) { if err != nil { t.Fatalf("GetAdmin: %v", err) } - if a.Username != "operator" || a.PasswordHash != "hash-two" { + if a.DMARCReportEmail != "reports@hub.example" { + t.Fatalf("dmarc email = %q", a.DMARCReportEmail) + } + + if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil { + t.Fatalf("clear dmarc email: %v", err) + } + a, err = st.GetAdmin() + if err != nil { + t.Fatalf("GetAdmin: %v", err) + } + if a.Username != "operator" || a.PasswordHash != "hash-three" { t.Fatalf("unexpected admin after update: %+v", a) } if a.CreatedAt.IsZero() { @@ -32,7 +43,7 @@ func TestUpdateAdmin(t *testing.T) { func TestUpdateAdminWithoutAdmin(t *testing.T) { st := openTestStore(t) - if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) { + if err := st.UpdateAdmin("operator", "hash", ""); !errors.Is(err, ErrNoAdmin) { t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err) } exists, err := st.AdminExists() diff --git a/internal/store/domains.go b/internal/store/domains.go index a370476..2fd9872 100644 --- a/internal/store/domains.go +++ b/internal/store/domains.go @@ -24,6 +24,7 @@ type Domain struct { ID int64 Name string DKIMSelector string + DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports CreatedAt time.Time AppCount int } @@ -54,7 +55,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) { // ordered by name. func (s *Store) ListDomains() ([]Domain, error) { 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) FROM domains d ORDER BY d.name`) @@ -78,7 +79,7 @@ func (s *Store) ListDomains() ([]Domain, error) { // ErrDomainNotFound. func (s *Store) GetDomain(id int64) (Domain, error) { 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) FROM domains d WHERE d.id = ?`, id) @@ -120,7 +121,7 @@ func scanDomain(r scanRow) (Domain, error) { d Domain 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 } d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) @@ -137,3 +138,21 @@ func isUniqueViolation(err error) bool { } 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 +} diff --git a/internal/store/domains_dmarc_test.go b/internal/store/domains_dmarc_test.go new file mode 100644 index 0000000..e2798b1 --- /dev/null +++ b/internal/store/domains_dmarc_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0004_dmarc_report_email.sql b/internal/store/migrations/0004_dmarc_report_email.sql new file mode 100644 index 0000000..f36408a --- /dev/null +++ b/internal/store/migrations/0004_dmarc_report_email.sql @@ -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; diff --git a/internal/web/auth/auth.go b/internal/web/auth/auth.go new file mode 100644 index 0000000..cc4bff3 --- /dev/null +++ b/internal/web/auth/auth.go @@ -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 +} diff --git a/internal/web/handlers_auth_test.go b/internal/web/auth/auth_test.go similarity index 52% rename from internal/web/handlers_auth_test.go rename to internal/web/auth/auth_test.go index 227c8a5..2c59422 100644 --- a/internal/web/handlers_auth_test.go +++ b/internal/web/auth/auth_test.go @@ -1,72 +1,95 @@ -package web +package auth import ( "net/http" "net/http/httptest" + "path/filepath" "strings" "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 -// backwards would make the development instance fail to log in at all, and -// silently — the browser discards the Set-Cookie and the panel just shows the -// login form again. +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 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) { - secure := &Server{cfg: Config{CookieSecure: true}} + secure := testModule(t, true) if got := secure.sessionCookie(); got != "__Host-selfpost_session" { 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" { 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) { - s := &Server{cfg: Config{CookieSecure: false}} + m := testModule(t, false) 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: "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) } } 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.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" { 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) { - s := &Server{cfg: Config{CookieSecure: true}} + m := testModule(t, true) 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"}) - if _, ok := s.sessionToken(r); ok { + if _, ok := m.sessionToken(r); ok { t.Fatal("the unprefixed cookie was accepted on a TLS deployment") } } func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)} - token := s.sessions.Create("admin") + m := testModule(t, false) + token := m.sessions.Create("admin") 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.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) { - s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)} - token := s.sessions.Create("admin") + m := testModule(t, true) + token := m.sessions.Create("admin") r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r.Host = "panel.example.com" r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token}) 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") } 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") + } +} diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go new file mode 100644 index 0000000..194014f --- /dev/null +++ b/internal/web/auth/handlers.go @@ -0,0 +1,239 @@ +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.AdminExists() + 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") + + admin, err := m.store.GetAdmin() + if err != nil { + if !errors.Is(err, store.ErrNoAdmin) { + logf("panel: login: get admin failed: %v", err) + } + m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") + return + } + + pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) + if username != admin.Username || pwErr != nil { + m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") + return + } + + token := m.sessions.Create(admin.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/ (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.CreateAdmin(username, string(hash)); err != nil { + if exists, _ := m.store.AdminExists(); exists { + m.setup.complete() + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + logf("panel: setup: create admin 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) +} diff --git a/internal/web/auth/middleware.go b/internal/web/auth/middleware.go new file mode 100644 index 0000000..9abdd3a --- /dev/null +++ b/internal/web/auth/middleware.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "net/http" +) + +type ctxKey int + +const usernameKey ctxKey = 0 + +// RequireAuth wraps a handler so only requests with a valid session cookie +// reach it; everyone else is redirected to the login page. The authenticated +// username is stashed in the request context for downstream handlers. +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) + } + ctx := context.WithValue(r.Context(), usernameKey, username) + 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 "" +} diff --git a/internal/web/ratelimit.go b/internal/web/auth/ratelimit.go similarity index 63% rename from internal/web/ratelimit.go rename to internal/web/auth/ratelimit.go index 664ad82..5658fc8 100644 --- a/internal/web/ratelimit.go +++ b/internal/web/auth/ratelimit.go @@ -1,4 +1,4 @@ -package web +package auth import ( "sync" @@ -6,9 +6,7 @@ import ( ) // 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 -// precise sliding window — a coarse backstop against brute-force and log noise -// is all these routes need. +// setup and login routes (security.md). Keys are client IPs. type rateLimiter struct { max int 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 { now := time.Now() r.mu.Lock() @@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool { 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) { for k, b := range r.buckets { if now.After(b.windowEnds) { diff --git a/internal/web/session.go b/internal/web/auth/session.go similarity index 75% rename from internal/web/session.go rename to internal/web/auth/session.go index daa0a8f..29c588d 100644 --- a/internal/web/session.go +++ b/internal/web/auth/session.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/sha256" @@ -22,10 +22,7 @@ const renewThreshold = time.Hour // cookie. type sessionStore struct { store *store.Store - // idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is - // no absolute cap: an administrator who keeps coming back stays signed in - // indefinitely, deliberately. - idle time.Duration + idle time.Duration } 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 { 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 { 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 -// 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. +// renewThreshold since the last extension, and reports whether it did so. func (s *sessionStore) Touch(token string) bool { hash := hashToken(token) row, found, err := s.store.LookupSession(hash) @@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool { if !found { return false } - // expiresAt = lastRenewal + idle, so this recovers when the session was - // last extended without a separate column. lastRenewal := row.ExpiresAt.Add(-s.idle) now := time.Now() if now.Sub(lastRenewal) < renewThreshold { @@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool { return true } -// Rename updates the username carried by a session, keeping its expiry. It is -// used when the administrator renames their own account so the current -// session keeps working under the new name. +// Rename updates the username carried by a session, keeping its expiry. func (s *sessionStore) Rename(token, username string) { if err := s.store.RenameSession(hashToken(token), username); err != nil { logf("panel: session: rename failed: %v", err) } } -// DestroyOthers invalidates every session except keep. It is called when the -// administrator changes their password: a stolen cookie issued under the old -// password must stop working, while the admin performing the change stays -// signed in. +// DestroyOthers invalidates every session except keep. func (s *sessionStore) DestroyOthers(keep string) { if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil { logf("panel: session: destroy others failed: %v", err) diff --git a/internal/web/setup.go b/internal/web/auth/setup.go similarity index 71% rename from internal/web/setup.go rename to internal/web/auth/setup.go index e162f05..8f28880 100644 --- a/internal/web/setup.go +++ b/internal/web/auth/setup.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/subtle" @@ -32,8 +32,6 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager 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 { done, err := m.store.AdminExists() if err != nil { @@ -49,9 +47,6 @@ func (m *setupManager) bootstrap() error { 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) { done, err := m.store.AdminExists() if err != nil { @@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) { 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 { token, ok := m.activeToken() if !ok { @@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool { 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() { m.mu.Lock() m.token = "" @@ -91,16 +80,12 @@ func (m *setupManager) complete() { m.clearTokenFile() } -// regenerateLocked mints a fresh token, announces it and mirrors it to disk. -// Caller holds m.mu. 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.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) { url := m.setupURL(token) logf("panel: ==================================================================") @@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) { if m.tokenPath == "" { return } - // 0600: the token is a bearer secret for creating the admin. 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) } diff --git a/internal/web/token.go b/internal/web/auth/token.go similarity index 97% rename from internal/web/token.go rename to internal/web/auth/token.go index 56b578f..0d4f4b3 100644 --- a/internal/web/token.go +++ b/internal/web/auth/token.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/rand" diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go new file mode 100644 index 0000000..3e4ec83 --- /dev/null +++ b/internal/web/handlers/handlers.go @@ -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...) +} diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go new file mode 100644 index 0000000..1f920bc --- /dev/null +++ b/internal/web/handlers/handlers_account.go @@ -0,0 +1,209 @@ +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 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 (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + admin, err := h.store.GetAdmin() + if err != nil { + logf("panel: account: get admin failed: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) + case http.MethodPost: + h.submitAccount(w, r) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// renderAccount draws the settings form. formUsername and formDMARCEmail +// repopulate fields after a rejected submission; password fields are never +// repopulated. +func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) { + var reportAuth dnscheck.Result + if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + reportAuth = h.dns.ReportAuth(ctx, hub) + cancel() + } + h.view.Render(w, status, "account", map[string]any{ + "Title": "SelfPost — settings", + "User": auth.CurrentUser(r), + "Active": "account", + "FormUsername": formUsername, + "FormDMARCEmail": formDMARCEmail, + "ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)), + "ReportAuthExample": dnscheck.ReportAuthExample(), + "ReportAuthDNS": reportAuth, + "ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail), + "Error": formErr, + "Flash": accountFlash(r), + }) +} + +// 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." + 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 "" + } +} + +// submitAccount applies a username and/or password change. The current password +// is always required, so a stolen session alone cannot lock the administrator +// out of their own panel, and the attempt is throttled on the same limiter as +// the login form so this route cannot be used to brute-force the password past +// that limit (security.md). +func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { + if !h.auth.AllowLoginAttempt(r) { + h.renderAccount(w, r, http.StatusTooManyRequests, + "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "") + return + } + if err := r.ParseForm(); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.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") + dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) + + admin, err := h.store.GetAdmin() + if err != nil { + logf("panel: account: get admin failed: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if username == "" { + username = admin.Username + } + + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { + h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) + return + } + + renaming := username != admin.Username + if renaming { + if err := validate.Username(username); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + return + } + } + + if err := validate.Email(dmarcEmail); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + return + } + + emailChanging := dmarcEmail != admin.DMARCReportEmail + + repassword := password != "" || confirm != "" + if repassword { + if password != confirm { + h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) + return + } + if err := validate.AdminPassword(password); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + 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) + 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) + h.renderAccount(w, r, http.StatusInternalServerError, + "Internal error. Please try again.", username, dmarcEmail) + return + } + hash = string(newHash) + } + + if err := h.store.UpdateAdmin(username, hash, dmarcEmail); 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." + } + h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) + return + } + + if token, ok := h.auth.SessionToken(r); ok { + if renaming { + h.auth.RenameSession(token, username) + } + if repassword { + h.auth.DestroyOtherSessions(token) + } + } + + logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging) + http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) +} + +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" + } +} diff --git a/internal/web/handlers_apps.go b/internal/web/handlers/handlers_apps.go similarity index 69% rename from internal/web/handlers_apps.go rename to internal/web/handlers/handlers_apps.go index 0da9824..ef243cd 100644 --- a/internal/web/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -10,6 +10,8 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" ) // newCred carries a freshly generated login/password to the template so it can @@ -48,28 +50,28 @@ type appRateLimitView struct { 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 // credentials (product.md). -func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { 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 // extras (form error/values, a one-time credential); everything else is loaded // 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) { - record, err := s.domains.DKIMRecord(d) +func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) { + record, err := h.domains.DKIMRecord(d) if err != nil { logf("panel: domain %d: dkim record: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - apps, err := s.apps.List(d.ID) + apps, err := h.apps.List(d.ID) if err != nil { logf("panel: domain %d: list applications: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -77,7 +79,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat } appViews := make([]appRateLimitView, 0, len(apps)) for _, a := range apps { - rl, ok, err := s.apps.RateLimit(a.ID) + rl, ok, err := h.apps.RateLimit(a.ID) if err != nil { logf("panel: application %d: rate limit: %v", a.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -92,7 +94,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 { logf("panel: domain %d: rate limit: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -102,11 +104,39 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // What DNS actually publishes for the domain today, checked against the key // this server signs with. Cached by the checker, so re-rendering the page // after a form post costs nothing. - dns, srv := s.domainDNS(d, record, false) + admin, err := h.store.GetAdmin() + if err != nil { + logf("panel: domain %d: get admin: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + profileEmail := admin.DMARCReportEmail + reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) + dns, srv := h.domainDNS(d, record, profileEmail, false) + reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) + 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{ + h.view.Render(w, status, "domain_detail", map[string]any{ "Title": "SelfPost — " + d.Name, - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "domains", "Domain": d, "Record": record, @@ -116,14 +146,23 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // this server expects rather than leaving it to the documentation. The // same builders phrase the suggestions in the check messages, so the // page and the checks below it never recommend different records. - "SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs), + "SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), "DMARCName": dnscheck.DMARCRecordName(d.Name), - "DMARCExample": dnscheck.DMARCExample(d.Name), + "DMARCExample": dnscheck.DMARCExample(reportEmail), + "DMARCSource": dmarcSource, + "ProfileDMARCEmail": profileEmail, + "ResolvedDMARCEmail": reportEmail, + "DMARCRuaMode": dmarcMode, + "DMARCRuaCustom": dmarcCustom, + "ReportAuthName": reportAuthName, + "ReportAuthValue": reportAuthValue, + "NeedsReportAuth": needsReportAuth, + "SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name), // Client connection settings (the same for every domain on this // instance): the hostname clients connect to, and whether the optional // submission listener is enabled in this deployment. - "Hostname": s.cfg.Hostname, - "SubmissionEnabled": s.cfg.SubmissionEnabled, + "Hostname": h.cfg.Hostname, + "SubmissionEnabled": h.cfg.SubmissionEnabled, "Apps": appViews, "Error": view.FormErr, "FormLogin": view.FormLogin, @@ -135,7 +174,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat "List": store.AddressModeList, "RateLimitErr": view.RateLimitErr, "ExportErr": view.ExportErr, - "MinPwLen": minSecretFilePasswordLen, + "MinPwLen": validate.MinSecretFilePasswordLen, "DomainHasRL": domainRLok && domainRL.Active(), "DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"), "DomainRLMax": intOrBlank(domainRL.MaxMessages), @@ -149,31 +188,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // 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 // 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) { - srv := s.dns.Server(s.cfg.Hostname, false) - return s.dns.Domain(dnscheck.Query{ - Name: d.Name, - Selector: d.DKIMSelector, - ExpectedDKIM: record.Value, - Hostname: srv.Hostname, - ServerIPs: srv.IPs, +func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { + srv := h.dns.Server(h.cfg.Hostname, false) + return h.dns.Domain(dnscheck.Query{ + Name: d.Name, + Selector: d.DKIMSelector, + ExpectedDKIM: record.Value, + Hostname: srv.Hostname, + ServerIPs: srv.IPs, + DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail), }, 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. -func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } - record, err := s.domains.DKIMRecord(d) + record, err := h.domains.DKIMRecord(d) if err != nil { logf("panel: domain %d: dkim record: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.domainDNS(d, record, true) + admin, err := h.store.GetAdmin() + if err != nil { + logf("panel: domain %d: get admin: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.domainDNS(d, record, admin.DMARCReportEmail, true) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) } @@ -205,6 +251,8 @@ func detailFlash(r *http.Request) string { return "Application address mode updated." case r.URL.Query().Get("ratelimit") != "": return "Rate limit updated." + case r.URL.Query().Get("dmarc") != "": + return "DMARC report settings updated." case r.URL.Query().Get("imported") != "": return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed." case r.URL.Query().Get("rechecked") != "": @@ -214,17 +262,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 // password cannot be recovered later, this deliberately renders inline rather // than redirecting. -func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } 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}) return } @@ -238,25 +286,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { 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 { repopulate.FormErr = applicationErrorMessage(err) status := http.StatusBadRequest if errors.Is(err, store.ErrLoginExists) { status = http.StatusConflict } - s.renderDomainDetail(w, r, status, d, repopulate) + h.renderDomainDetail(w, r, status, d, repopulate) return } - s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ + h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ FormMode: store.AddressModeWildcard, NewCred: &newCred{Login: a.Login, Password: password}, }) } -// handleUpdateAppMode switches an application's address mode / list (product.md). -func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +// HandleUpdateAppMode switches an application's address mode / list (product.md). +func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } @@ -267,13 +315,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { mode := r.PostFormValue("mode") addrs := splitAddresses(r.PostFormValue("addresses")) - if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil { - d, derr := s.domains.Get(a.DomainID) + if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil { + d, derr := h.domains.Get(a.DomainID) if derr != nil { http.Error(w, "internal error", http.StatusInternalServerError) 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)), FormMode: store.AddressModeWildcard, }) @@ -282,38 +330,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) } -// 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. -func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } - d, err := s.domains.Get(a.DomainID) + d, err := h.domains.Get(a.DomainID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - password, err := s.apps.RegeneratePassword(a.ID) + password, err := h.apps.RegeneratePassword(a.ID) if err != nil { logf("panel: regenerate password for application %d: %v", a.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.renderDomainDetail(w, r, http.StatusOK, d, detailView{ + h.renderDomainDetail(w, r, http.StatusOK, d, detailView{ FormMode: store.AddressModeWildcard, 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). -func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { 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) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -323,13 +371,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) // lookupApplication resolves the {aid} path value to an application, writing a // 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) if err != nil || id <= 0 { http.NotFound(w, r) return store.Application{}, false } - a, err := s.apps.Get(id) + a, err := h.apps.Get(id) if err != nil { if errors.Is(err, store.ErrApplicationNotFound) { http.NotFound(w, r) diff --git a/internal/web/handlers_backup.go b/internal/web/handlers/handlers_backup.go similarity index 81% rename from internal/web/handlers_backup.go rename to internal/web/handlers/handlers_backup.go index 4b1d45f..8355360 100644 --- a/internal/web/handlers_backup.go +++ b/internal/web/handlers/handlers_backup.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "bytes" @@ -13,6 +13,8 @@ import ( "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" ) // maxImportBytes caps a domain-import upload. A domain export is a small JSON @@ -22,35 +24,35 @@ import ( // covers both forms. 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 // its own card here rather than sharing a block on the domain list. -func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) { - s.renderBackupPage(w, r, http.StatusOK, "") +func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) { + h.renderBackupPage(w, r, http.StatusOK, "") } // renderBackupPage draws the page; importErr surfaces a failed domain import // (architecture.md § Persistence) next to the form that produced it. -func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { - s.renderBackupPageWith(w, r, status, importErr, "") +func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { + h.renderBackupPageWith(w, r, status, importErr, "") } // renderBackupPageWith is renderBackupPage with the second of the page's two // error slots: backupErr belongs to the full-backup card (a rejected encryption // password), importErr to the import card, so neither message appears under the // wrong form. -func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { - s.render(w, status, "backup", map[string]any{ +func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { + h.view.Render(w, status, "backup", map[string]any{ "Title": "SelfPost — backup", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "backup", "ImportErr": importErr, "BackupErr": backupErr, - "MinPwLen": minSecretFilePasswordLen, + "MinPwLen": validate.MinSecretFilePasswordLen, }) } -// 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 // 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 @@ -58,10 +60,10 @@ 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 // way out, so the file that lands on their disk — wherever it is copied // 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) { password, pwErr := secretFilePassword(r) if pwErr != "" { - s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) + h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) return } @@ -99,9 +101,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { } if err := backup.Create(sink, backup.Params{ - DataDir: s.cfg.DataDir, - DBPath: s.cfg.DBPath, - Version: s.cfg.Version, + DataDir: h.cfg.DataDir, + DBPath: h.cfg.DBPath, + Version: h.cfg.Version, }); err != nil { logf("panel: full backup failed: %v", err) return @@ -113,26 +115,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 // 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 // full backup it can be encrypted with a password, in which case the download // is a .spde envelope instead of plain JSON. -func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } password, pwErr := secretFilePassword(r) if pwErr != "" { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, ExportErr: pwErr, }) return } - exp, err := s.domains.Export(d.ID) + exp, err := h.domains.Export(d.ID) if err != nil { logf("panel: export domain %d: %v", d.ID, err) http.Error(w, "export failed", http.StatusInternalServerError) @@ -174,22 +176,22 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { _, _ = 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 // normalised and validated here (security.md); the domain service validates // 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 // re-renders the backup page, where the import form lives, with a friendly // message. -func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) 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 } file, _, err := r.FormFile("file") 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 } defer file.Close() @@ -201,7 +203,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { head := make([]byte, secretfile.MagicLen) n, err := io.ReadFull(file, head) 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 } source := io.MultiReader(bytes.NewReader(head[:n]), file) @@ -209,16 +211,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { if secretfile.HasMagic(head[:n]) { 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 } env, err := secretfile.NewReader(source, password) if err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) + h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) return } 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 } // Read the whole plaintext first: authentication of the last chunk is @@ -226,12 +228,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { // accept a truncated document before ever reaching it. plain, err := io.ReadAll(env) if err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) + h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) return } source = bytes.NewReader(plain) } 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 } @@ -239,23 +241,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(source) dec.DisallowUnknownFields() 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 } // Normalise and validate the domain name before it reaches the service, the // same gate the add-domain form uses (security.md). - exp.Domain = normalizeDomain(exp.Domain) - if err := validateDomain(exp.Domain); err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) + exp.Domain = validate.NormalizeDomain(exp.Domain) + if err := validate.Domain(exp.Domain); err != nil { + h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) return } - d, err := s.domains.Import(exp) + d, err := h.domains.Import(exp) if err != nil { logf("panel: import domain %q: %v", exp.Domain, err) status, msg := importErrorMessage(err) - s.renderBackupPage(w, r, status, msg) + h.renderBackupPage(w, r, status, msg) return } http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) @@ -276,8 +278,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) { return "", "" } password = r.PostFormValue("password") - if len([]rune(password)) < minSecretFilePasswordLen { - return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen) + if len([]rune(password)) < validate.MinSecretFilePasswordLen { + return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen) } if password != r.PostFormValue("password_confirm") { return "", "The two passwords do not match." diff --git a/internal/web/handlers_backup_test.go b/internal/web/handlers/handlers_backup_test.go similarity index 89% rename from internal/web/handlers_backup_test.go rename to internal/web/handlers/handlers_backup_test.go index e614e04..2660cf7 100644 --- a/internal/web/handlers_backup_test.go +++ b/internal/web/handlers/handlers_backup_test.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -10,6 +10,7 @@ import ( "testing" "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. @@ -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 plain archive earlier versions produced. func TestSecretFilePassword(t *testing.T) { - long := strings.Repeat("x", minSecretFilePasswordLen) - short := strings.Repeat("x", minSecretFilePasswordLen-1) + long := strings.Repeat("x", validate.MinSecretFilePasswordLen) + short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1) tests := []struct { name string @@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) { // forgets to include the partial (or the data it needs) loses the option // silently, since the plain download still works. func TestBackupPageOffersEncryption(t *testing.T) { - tmpl, err := loadTemplates() - if err != nil { - t.Fatalf("loadTemplates: %v", err) - } - s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}} + h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}} 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.") body := rec.Body.String() @@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) { `name="encrypt"`, `name="password"`, `name="password_confirm"`, `name="import_password"`, "data-encrypt-toggle", "data-encrypt-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.", } { if !strings.Contains(body, want) { diff --git a/internal/web/handlers/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go new file mode 100644 index 0000000..12c1e74 --- /dev/null +++ b/internal/web/handlers/handlers_dmarc.go @@ -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) +} diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go new file mode 100644 index 0000000..4c29b89 --- /dev/null +++ b/internal/web/handlers/handlers_domains.go @@ -0,0 +1,186 @@ +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) { + domains, err := h.domains.List() + if err != nil { + logf("panel: dashboard: list domains: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.view.Render(w, status, "dashboard", map[string]any{ + "Title": "SelfPost", + "User": auth.CurrentUser(r), + "Active": "domains", + "Domains": h.domainRows(domains), + "Error": formErr, + "FormName": formName, + "Flash": dashboardFlash(r), + }) +} + +func (h *Handlers) domainRows(domains []store.Domain) []domainRow { + profileEmail := "" + if admin, err := h.store.GetAdmin(); err == nil { + profileEmail = admin.DMARCReportEmail + } + 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 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) { + 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, + }) +} + +// HandleDeleteDomain performs the deletion and returns to the domain list. +func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { + 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 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 + } + 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 +} diff --git a/internal/web/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go similarity index 83% rename from internal/web/handlers_monitor.go rename to internal/web/handlers/handlers_monitor.go index ba1e808..fb4d936 100644 --- a/internal/web/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -12,6 +12,7 @@ import ( "github.com/mixeme/selfpost/internal/mailhdr" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" ) // sendLogPageSize bounds each send-log page (product.md's monitoring screens @@ -24,38 +25,38 @@ const ( 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 § // 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. -func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { - data, err := s.sendLogData(r) +func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) { + data, err := h.sendLogData(r) if err != nil { logf("panel: send log: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } data["Title"] = "SelfPost — deliveries" - data["User"] = currentUser(r) + data["User"] = auth.CurrentUser(r) 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 // JSON). -func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { - data, err := s.sendLogData(r) +func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) { + data, err := h.sendLogData(r) if err != nil { logf("panel: send log rows: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) 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, // 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 @@ -67,13 +68,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. // 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. -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) if err != nil || id <= 0 { http.NotFound(w, r) return } - row, err := s.store.GetSendLog(id) + row, err := h.store.GetSendLog(id) if err != nil { // A row pruned on the retention window is gone, not broken. if errors.Is(err, store.ErrSendLogNotFound) { @@ -85,10 +86,10 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) { return } row.Subject = mailhdr.DecodeSubject(row.Subject) - logRows, logNote := s.deliveryLog(row) - s.render(w, http.StatusOK, "delivery", map[string]any{ + logRows, logNote := h.deliveryLog(row) + h.view.Render(w, http.StatusOK, "delivery", map[string]any{ "Title": "SelfPost — delivery", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "deliveries", "Row": row, // The status in the panel's own badge vocabulary, so the headline reads @@ -223,11 +224,11 @@ type deliveryLogRow struct { // 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 // 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 == "" { 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) { logf("panel: delivery log %s: %v", row.QueueID, err) return nil, "Could not read the mail log." @@ -267,7 +268,7 @@ func deliveriesBackURL(r *http.Request) string { // sendLogData reads the domain/app filters and page number off the query // string, queries the store, and assembles everything the template needs // (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) { q := r.URL.Query() filter := store.SendLogFilter{ Domain: q.Get("domain"), @@ -275,11 +276,11 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { } page := parsePage(q.Get("p")) - total, err := s.store.CountSendLog(filter) + total, err := h.store.CountSendLog(filter) if err != nil { 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 { return nil, err } @@ -289,7 +290,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { for i := range rows { rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) } - domains, err := s.store.ListDomains() + domains, err := h.store.ListDomains() if err != nil { return nil, err } @@ -297,7 +298,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { for i, d := range domains { domainNames[i] = d.Name } - logins, err := s.store.ListApplicationLogins() + logins, err := h.store.ListApplicationLogins() if err != nil { return nil, err } @@ -331,23 +332,23 @@ func parsePage(v string) int { return n } -// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP +// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP // surface). -func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) { 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", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "mail_queue", "Output": out, "Error": errText, }) } -// handleMailQueueBody serves the HTMX polling fragment for the queue view. -func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) { +// HandleMailQueueBody serves the HTMX polling fragment for the queue view. +func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) { 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, "Error": errText, }) @@ -365,30 +366,30 @@ func readQueue() (string, string) { 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). -func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) { - lines, errText := s.readLogTail() - s.render(w, http.StatusOK, "system_log", map[string]any{ +func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) { + lines, errText := h.readLogTail() + h.view.Render(w, http.StatusOK, "system_log", map[string]any{ "Title": "SelfPost — system log", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "system_log", "Lines": lines, "Error": errText, }) } -// handleSystemLogBody serves the HTMX polling fragment for the log-tail view. -func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) { - lines, errText := s.readLogTail() - s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{ +// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view. +func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) { + lines, errText := h.readLogTail() + h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{ "Lines": lines, "Error": errText, }) } -func (s *Server) readLogTail() ([]string, string) { - lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines) +func (h *Handlers) readLogTail() ([]string, string) { + lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines) if err != nil { if errors.Is(err, fs.ErrNotExist) { // Rotation renamed the file away; Postfix recreates it on reload @@ -396,7 +397,7 @@ func (s *Server) readLogTail() ([]string, string) { // than a failure worth alarming the operator about. 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 lines, "" diff --git a/internal/web/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go similarity index 86% rename from internal/web/handlers_monitor_test.go rename to internal/web/handlers/handlers_monitor_test.go index ec8d01b..aaf49ab 100644 --- a/internal/web/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "net/http" @@ -16,9 +16,9 @@ import ( // recreate it on reload (spec B.2); a missing file in that window is a normal, // transient gap, not an operator-facing failure. 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 { t.Errorf("lines = %v, want nil", lines) } @@ -31,9 +31,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) { // 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. 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{ row.CreatedAt.Format("2006-01-02 15:04:05"), "noreply@bs.example.ru", "public@example.ru", @@ -56,11 +56,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) { // 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. func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) { - s, _ := serverWithDelivery(t) + h, _ := serverWithDelivery(t) for name, out := range map[string]string{ - "log": getBody(t, s.handleDeliveries, "/deliveries"), - "rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"), + "log": getBody(t, h.HandleDeliveries, "/deliveries"), + "rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"), } { if strings.Contains(out, "=?utf-8?Q?") { t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out) @@ -74,9 +74,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) { // 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. 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{ "bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка", "noreply@bs.example.ru", "public@example.ru", "sent", @@ -95,9 +95,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) { // 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. 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{ "Accepted and queued", "Delivered", row.CreatedAt.Format("2006-01-02 15:04:05"), @@ -121,19 +121,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) { // for is drawn as one that has not happened rather than dated with the moment // the row was written. func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { - s, _ := serverWithDelivery(t) - if err := s.store.InsertQueued(store.SendLogEntry{ + h, _ := serverWithDelivery(t) + if err := h.store.InsertQueued(store.SendLogEntry{ QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going", }); err != nil { 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 { 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"} { if !strings.Contains(out, want) { t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out) @@ -146,14 +146,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { // lines — as a table of when and what, so the seconds between the connection // and the reply line up down one edge. func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = writeMailLog(t, + h, row := serverWithDelivery(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.300000+00:00 host postfix/qmgr[10]: 99999999: from=, size=500, nrcpt=1 (queue active)", "2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=, 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{ "Time", "Message", // The stamp is split off into its own cell, without the microseconds @@ -174,10 +174,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) { // 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. func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)") + h, row := serverWithDelivery(t) + h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, 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)") { t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out) } @@ -186,10 +186,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) { // 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. func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created + h, row := serverWithDelivery(t) + 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") { t.Errorf("delivery page does not explain the empty delivery log:\n%s", out) } @@ -201,13 +201,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { // Send-log rows are pruned on the retention window, so a bookmarked delivery // that no longer exists is a 404, not a 500. func TestDeliveryPageNotFound(t *testing.T) { - s, _ := serverWithDelivery(t) + h, _ := serverWithDelivery(t) for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/")) - s.handleDelivery(rec, req) + h.HandleDelivery(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", path, rec.Code) } @@ -216,7 +216,7 @@ func TestDeliveryPageNotFound(t *testing.T) { // serverWithDelivery builds a panel over a store holding one delivery, written // 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() st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -242,11 +242,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) { t.Fatalf("query: %v (%d rows)", err, len(rows)) } - tmpl, err := loadTemplates() - if err != nil { - t.Fatalf("loadTemplates: %v", err) - } - return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0] + return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0] } // getBody runs one handler over a GET and returns the page it wrote, failing diff --git a/internal/web/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go similarity index 57% rename from internal/web/handlers_ratelimit.go rename to internal/web/handlers/handlers_ratelimit.go index 410b7ec..e7e9550 100644 --- a/internal/web/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "fmt" @@ -10,15 +10,8 @@ import ( "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 -// 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 { clear bool ips []string @@ -26,10 +19,6 @@ type rateLimitInput struct { 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) { if err := r.ParseForm(); err != nil { return rateLimitInput{}, fmt.Errorf("invalid form submission") @@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { return rateLimitInput{}, err } if len(ips) == 0 { - // No IP binding: the differentiated limit does not apply (guide § Rate - // limiting). return rateLimitInput{clear: true}, nil } 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 } -// 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) { fields := strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';' @@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) { 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) { raw = strings.TrimSpace(raw) if raw == "" { @@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) { return strconv.Atoi(raw) } -// handleDomainRateLimit saves or clears a domain-level differentiated rate -// limit (guide § Rate limiting). No reload is needed — the milter reads the -// row live. -func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } in, err := parseRateLimitForm(r) if err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, RateLimitErr: err.Error(), }) 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) http.Error(w, "internal error", http.StatusInternalServerError) 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) } -// handleAppRateLimit saves or clears an application-level differentiated rate -// limit (guide § Rate limiting). -func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } - d, err := s.domains.Get(a.DomainID) + d, err := h.domains.Get(a.DomainID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } in, err := parseRateLimitForm(r) if err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()), }) 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) http.Error(w, "internal error", http.StatusInternalServerError) 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) } -// applyRateLimit dispatches a validated input to the save or clear method of the -// relevant service, keyed by the domain or application id. -func (s *Server) applyRateLimit( +func (h *Handlers) applyRateLimit( in rateLimitInput, save func(id int64, ips []string, maxMessages, windowSeconds int) error, clear func(id int64) error, diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go new file mode 100644 index 0000000..c731860 --- /dev/null +++ b/internal/web/handlers/handlers_status.go @@ -0,0 +1,111 @@ +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) { + data := h.statusBody() + srv := h.dns.Server(h.cfg.Hostname, false) + + data["Title"] = "SelfPost — status" + data["User"] = auth.CurrentUser(r) + data["Active"] = "status" + data["Flash"] = statusFlash(r) + data["Hostname"] = h.cfg.Hostname + data["PTR"] = srv.PTR + h.view.Render(w, http.StatusOK, "status", data) +} + +func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) { + h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody()) +} + +func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) { + 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 "" + } +} diff --git a/internal/web/handlers/testutil_test.go b/internal/web/handlers/testutil_test.go new file mode 100644 index 0000000..a3c0bf6 --- /dev/null +++ b/internal/web/handlers/testutil_test.go @@ -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 +} diff --git a/internal/web/handlers_account.go b/internal/web/handlers_account.go deleted file mode 100644 index 9cbdf0a..0000000 --- a/internal/web/handlers_account.go +++ /dev/null @@ -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 — settings", - "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" - } -} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go deleted file mode 100644 index 72c546e..0000000 --- a/internal/web/handlers_auth.go +++ /dev/null @@ -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
, 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) -} diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go deleted file mode 100644 index 767e4fa..0000000 --- a/internal/web/handlers_domains.go +++ /dev/null @@ -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 -} diff --git a/internal/web/handlers_license_test.go b/internal/web/handlers_license_test.go new file mode 100644 index 0000000..63b14b7 --- /dev/null +++ b/internal/web/handlers_license_test.go @@ -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) + } +} diff --git a/internal/web/handlers_setup.go b/internal/web/handlers_setup.go deleted file mode 100644 index 06fcf2a..0000000 --- a/internal/web/handlers_setup.go +++ /dev/null @@ -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/ (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) -} diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go deleted file mode 100644 index 2216652..0000000 --- a/internal/web/handlers_status.go +++ /dev/null @@ -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 "" - } -} diff --git a/internal/web/middleware.go b/internal/web/middleware.go deleted file mode 100644 index 0397b02..0000000 --- a/internal/web/middleware.go +++ /dev/null @@ -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 "" -} diff --git a/internal/web/session_test.go b/internal/web/session_test.go deleted file mode 100644 index 3644d26..0000000 --- a/internal/web/session_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html deleted file mode 100644 index 89a5162..0000000 --- a/internal/web/templates/account.html +++ /dev/null @@ -1,32 +0,0 @@ -{{define "content"}} -

Settings

- -{{if .Flash}}
{{.Flash}}
{{end}} - -
-

Panel credentials

-

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

- {{if .Error}}

{{.Error}}

{{end}} -
- - - - - - - - - - - - - -
-

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

-
-{{end}} diff --git a/internal/web/validate.go b/internal/web/validate.go deleted file mode 100644 index a53483b..0000000 --- a/internal/web/validate.go +++ /dev/null @@ -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 -} diff --git a/internal/web/validate/validate.go b/internal/web/validate/validate.go new file mode 100644 index 0000000..6ba5cde --- /dev/null +++ b/internal/web/validate/validate.go @@ -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 +} diff --git a/internal/web/validate/validate_email_test.go b/internal/web/validate/validate_email_test.go new file mode 100644 index 0000000..9e09be0 --- /dev/null +++ b/internal/web/validate/validate_email_test.go @@ -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") + } +} diff --git a/internal/web/validate/validate_test.go b/internal/web/validate/validate_test.go new file mode 100644 index 0000000..6a20bf4 --- /dev/null +++ b/internal/web/validate/validate_test.go @@ -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") + } +} diff --git a/internal/web/validate_test.go b/internal/web/validate_test.go deleted file mode 100644 index b7556e4..0000000 --- a/internal/web/validate_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/web/static.go b/internal/web/view/static.go similarity index 94% rename from internal/web/static.go rename to internal/web/view/static.go index d4b70d5..ba798b8 100644 --- a/internal/web/static.go +++ b/internal/web/view/static.go @@ -1,4 +1,4 @@ -package web +package view import ( "crypto/sha256" @@ -39,7 +39,7 @@ func buildStaticETags() map[string]string { return etags } -// staticHandler serves the embedded assets under /static/ with a content ETag. +// 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 @@ -47,7 +47,7 @@ func buildStaticETags() map[string]string { // 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 { +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 { diff --git a/internal/web/static/favicon.png b/internal/web/view/static/favicon.png similarity index 100% rename from internal/web/static/favicon.png rename to internal/web/view/static/favicon.png diff --git a/internal/web/static/favicon.svg b/internal/web/view/static/favicon.svg similarity index 100% rename from internal/web/static/favicon.svg rename to internal/web/view/static/favicon.svg diff --git a/internal/web/static/htmx.min.js b/internal/web/view/static/htmx.min.js similarity index 100% rename from internal/web/static/htmx.min.js rename to internal/web/view/static/htmx.min.js diff --git a/internal/web/static/logo-compact.svg b/internal/web/view/static/logo-compact.svg similarity index 100% rename from internal/web/static/logo-compact.svg rename to internal/web/view/static/logo-compact.svg diff --git a/internal/web/static/logo.svg b/internal/web/view/static/logo.svg similarity index 100% rename from internal/web/static/logo.svg rename to internal/web/view/static/logo.svg diff --git a/internal/web/static/panel.css b/internal/web/view/static/panel.css similarity index 98% rename from internal/web/static/panel.css rename to internal/web/view/static/panel.css index bddd573..e2e155c 100644 --- a/internal/web/static/panel.css +++ b/internal/web/view/static/panel.css @@ -176,7 +176,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } .back { display: block; margin-bottom: 1rem; } /* Build version, closing every authenticated page. Quiet on purpose: it is 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: #6b7280; line-height: 1.45; } +.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } +.version a:hover { color: var(--fg); } +/* The signed-out column is 24rem; a right-aligned multi-link notice wraps + into a ragged edge, so centre it there. */ +main.page-login .version, main.page-setup .version { text-align: center; } select, textarea { 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; diff --git a/internal/web/static/panel.js b/internal/web/view/static/panel.js similarity index 100% rename from internal/web/static/panel.js rename to internal/web/view/static/panel.js diff --git a/internal/web/static_test.go b/internal/web/view/static_test.go similarity index 98% rename from internal/web/static_test.go rename to internal/web/view/static_test.go index 17b71f2..3954af0 100644 --- a/internal/web/static_test.go +++ b/internal/web/view/static_test.go @@ -1,4 +1,4 @@ -package web +package view import ( "net/http" @@ -13,7 +13,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor r.Header.Set(k, v) } rec := httptest.NewRecorder() - staticHandler().ServeHTTP(rec, r) + StaticHandler().ServeHTTP(rec, r) return rec } diff --git a/internal/web/view/templates/account.html b/internal/web/view/templates/account.html new file mode 100644 index 0000000..e515568 --- /dev/null +++ b/internal/web/view/templates/account.html @@ -0,0 +1,68 @@ +{{define "content"}} +

Settings

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

Panel credentials

+

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

+ {{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + + + + + + +

DMARC aggregate reports

+

Optional default rua= address for every sending + domain (can be overridden per domain). Use a mailbox on a domain that + receives inbound mail. SelfPost is send-only today; a future release will + be able to receive reports in the panel itself.

+ + + + + {{if .FormDMARCEmail}} +

When rua= points at another domain, that hub + domain must publish a report-authorisation record so receivers will deliver + the XML aggregates.

+ + +
+ {{.ReportAuthName}} + +
+ + +
+ {{.ReportAuthExample}} + +
+ + {{if .ReportAuthDNS.Status}} + +

{{.ReportAuthDNS.Detail}}

+ {{if .ReportAuthDNS.Records}}{{range .ReportAuthDNS.Records}}{{.}} +{{end}}{{end}} + {{end}} + {{end}} + + +
+

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

+
+{{end}} diff --git a/internal/web/templates/backup.html b/internal/web/view/templates/backup.html similarity index 100% rename from internal/web/templates/backup.html rename to internal/web/view/templates/backup.html diff --git a/internal/web/templates/dashboard.html b/internal/web/view/templates/dashboard.html similarity index 100% rename from internal/web/templates/dashboard.html rename to internal/web/view/templates/dashboard.html diff --git a/internal/web/templates/deliveries.html b/internal/web/view/templates/deliveries.html similarity index 100% rename from internal/web/templates/deliveries.html rename to internal/web/view/templates/deliveries.html diff --git a/internal/web/templates/deliveries_rows.html b/internal/web/view/templates/deliveries_rows.html similarity index 100% rename from internal/web/templates/deliveries_rows.html rename to internal/web/view/templates/deliveries_rows.html diff --git a/internal/web/templates/delivery.html b/internal/web/view/templates/delivery.html similarity index 100% rename from internal/web/templates/delivery.html rename to internal/web/view/templates/delivery.html diff --git a/internal/web/templates/domain_delete.html b/internal/web/view/templates/domain_delete.html similarity index 100% rename from internal/web/templates/domain_delete.html rename to internal/web/view/templates/domain_delete.html diff --git a/internal/web/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html similarity index 84% rename from internal/web/templates/domain_detail.html rename to internal/web/view/templates/domain_detail.html index 93a7c0a..f9ec373 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -51,8 +51,10 @@

SPF and DMARC records

These two are not generated the way the DKIM record above is — they are policy, and the domain may already publish an SPF record for other - senders. What follows is what this server expects to see and what the checks - below look for. Publish both as TXT records.

+ senders. SelfPost is a send-only relay — most operators have no inbox on the + sending domain, so the suggested DMARC record omits rua= unless a + report address is configured below or in Settings. + Publish both as TXT records.

@@ -68,9 +70,23 @@

A domain may have only one SPF record. If it already has one, do not add a second — add this server's mechanism to the existing record - instead, before its all term. -all at the end means - nothing else may send as the domain; use ~all while other senders - are still being sorted out.

+ instead, before its all term.

+ +
+ + + + + + + +
@@ -78,17 +94,36 @@
- +
{{.DMARCExample}}
-

Point rua= at a mailbox that is actually read — - the aggregate reports are how you find out who else sends as the domain. - p=none changes nothing about delivery, so it is safe to publish - straight away; tighten it to p=quarantine and then - p=reject once the reports come back clean.

+ {{if .SameDomainRUA}} +

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

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

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

@@ -116,6 +151,13 @@ {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} {{end}}{{end}} + {{if .DNS.DMARCReportAuth.Status}} + +

{{.DNS.DMARCReportAuth.Detail}}

+ {{if .DNS.DMARCReportAuth.Records}}{{range .DNS.DMARCReportAuth.Records}}{{.}} +{{end}}{{end}} + {{end}} +
diff --git a/internal/web/templates/encrypt_fields.html b/internal/web/view/templates/encrypt_fields.html similarity index 100% rename from internal/web/templates/encrypt_fields.html rename to internal/web/view/templates/encrypt_fields.html diff --git a/internal/web/templates/layout.html b/internal/web/view/templates/layout.html similarity index 93% rename from internal/web/templates/layout.html rename to internal/web/view/templates/layout.html index 8cdaed5..d1d868e 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -37,11 +37,17 @@ tell the send log from a single delivery's page. */}}
{{template "content" .}} -{{/* The running version, on every authenticated page: it is what a backup - manifest is checked against on restore and the first thing to establish - when something behaves unexpectedly. Only for signed-in administrators — - the login and setup pages must not advertise it to the internet. */}} -{{if .User}}
SelfPost {{.Version}}
{{end}} +{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence, + where the Corresponding Source is, and that there is no warranty. Shown + on every page, including login/setup — those are interactive UIs too. + The running version stays signed-in only: it is what a backup manifest is + checked against on restore, and must not be advertised to the internet. */}} +
diff --git a/internal/web/templates/login.html b/internal/web/view/templates/login.html similarity index 100% rename from internal/web/templates/login.html rename to internal/web/view/templates/login.html diff --git a/internal/web/templates/mail_queue.html b/internal/web/view/templates/mail_queue.html similarity index 100% rename from internal/web/templates/mail_queue.html rename to internal/web/view/templates/mail_queue.html diff --git a/internal/web/templates/mail_queue_body.html b/internal/web/view/templates/mail_queue_body.html similarity index 100% rename from internal/web/templates/mail_queue_body.html rename to internal/web/view/templates/mail_queue_body.html diff --git a/internal/web/templates/setup.html b/internal/web/view/templates/setup.html similarity index 100% rename from internal/web/templates/setup.html rename to internal/web/view/templates/setup.html diff --git a/internal/web/templates/status.html b/internal/web/view/templates/status.html similarity index 100% rename from internal/web/templates/status.html rename to internal/web/view/templates/status.html diff --git a/internal/web/templates/status_body.html b/internal/web/view/templates/status_body.html similarity index 100% rename from internal/web/templates/status_body.html rename to internal/web/view/templates/status_body.html diff --git a/internal/web/templates/system_log.html b/internal/web/view/templates/system_log.html similarity index 100% rename from internal/web/templates/system_log.html rename to internal/web/view/templates/system_log.html diff --git a/internal/web/templates/system_log_body.html b/internal/web/view/templates/system_log_body.html similarity index 100% rename from internal/web/templates/system_log_body.html rename to internal/web/view/templates/system_log_body.html diff --git a/internal/web/templates_test.go b/internal/web/view/templates_test.go similarity index 83% rename from internal/web/templates_test.go rename to internal/web/view/templates_test.go index c176e33..767a6dc 100644 --- a/internal/web/templates_test.go +++ b/internal/web/view/templates_test.go @@ -1,4 +1,4 @@ -package web +package view import ( "bytes" @@ -18,11 +18,11 @@ import ( // every page template must resolve it. This is what makes "the nav is on every // authenticated page" a structural property instead of a checklist item. func TestEveryPageResolvesNav(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { if page.Lookup("nav") == nil { t.Errorf("page %q does not resolve the shared nav template", name) } @@ -37,16 +37,16 @@ func TestEveryPageResolvesNav(t *testing.T) { // asserted here: the long pages produce a list, and a page that defines nothing // produces nothing at all. func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } // Anchors the index links to, taken from the page's own cards. wantAnchors := map[string]string{ "status": `href="#certificate"`, "domain_detail": `href="#danger"`, } - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { var buf bytes.Buffer // The domain page's index hides the freshly generated credential entry // unless one is on the page, so the data map carries the key it reads. @@ -68,15 +68,15 @@ func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { // nothing about rendering the page says so. Every anchor the index offers must // name an element the same page defines an id for. func TestSectionLinksPointAtCardsThatExist(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } // The pages that carry an index; both are checked with a credential shown, // which is the domain page's one conditional entry. for _, name := range []string{"status", "domain_detail"} { var index bytes.Buffer - if err := tmpl.pages[name].ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { + if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { t.Fatalf("execute sections for %q: %v", name, err) } // The cards are spread over the page's template files, so the ids are @@ -105,16 +105,28 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) { // The version comes from render(), not from each handler's data map, so the // footer is only correct as long as every page composes with the layout and // render keeps supplying the key. Both are asserted here rather than trusted. +// Appropriate Legal Notices (copyright, licence, source, no warranty) must +// appear on every page, including the signed-out ones. func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("9.9.9-test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) + } + legalBits := []string{ + "Copyright © 2026 Mikhail Yenuchenko", + `href="/license"`, + "License (AGPL-3.0)", + `href="https://github.com/mixeme/selfpost"`, + "Source", + "No warranty", } rendered := 0 - for name := range tmpl.pages { + for name := range engine.Pages() { var buf bytes.Buffer - err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{ + err := engine.Page(name).ExecuteTemplate(&buf, "layout.html", map[string]any{ "Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test", + "Copyright": "Copyright © 2026 Mikhail Yenuchenko", + "SourceURL": "https://github.com/mixeme/selfpost", }) if err != nil { // Pages whose content block needs more data than this cannot be @@ -123,35 +135,49 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { continue } rendered++ - if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") { + out := buf.String() + if !strings.Contains(out, "SelfPost 9.9.9-test") { t.Errorf("page %q does not show the version in the layout footer", name) } + for _, want := range legalBits { + if !strings.Contains(out, want) { + t.Errorf("page %q is missing legal notice %q", name, want) + } + } } if rendered == 0 { t.Fatal("no page rendered, so the footer was never actually checked") } - // Signed out (login, setup) the version must not be advertised. + // Signed out (login, setup) the version must not be advertised, but the + // Appropriate Legal Notices must still be present. var buf bytes.Buffer - if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{ + if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{ "Title": "t", "Active": "", "Version": "9.9.9-test", + "Copyright": "Copyright © 2026 Mikhail Yenuchenko", + "SourceURL": "https://github.com/mixeme/selfpost", }); err != nil { t.Fatalf("execute login: %v", err) } - if strings.Contains(buf.String(), "9.9.9-test") { - t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String()) + out := buf.String() + if strings.Contains(out, "9.9.9-test") { + t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", out) + } + for _, want := range legalBits { + if !strings.Contains(out, want) { + t.Errorf("login page is missing legal notice %q", want) + } } } func TestRenderSuppliesTheVersion(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("9.9.9-test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } - s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}} rec := httptest.NewRecorder() data := map[string]any{"Title": "t", "User": "admin"} - s.render(rec, http.StatusOK, "backup", data) + engine.Render(rec, http.StatusOK, "backup", data) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -159,18 +185,28 @@ func TestRenderSuppliesTheVersion(t *testing.T) { if got := data["Version"]; got != "9.9.9-test" { t.Errorf("render did not supply Version (got %v)", got) } - if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") { - t.Errorf("rendered page does not show the version:\n%s", rec.Body.String()) + if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" { + t.Errorf("render did not supply Copyright (got %v)", got) + } + if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" { + t.Errorf("render did not supply SourceURL (got %v)", got) + } + body := rec.Body.String() + if !strings.Contains(body, "SelfPost 9.9.9-test") { + t.Errorf("rendered page does not show the version:\n%s", body) + } + if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") { + t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body) } } func TestNavMarksActivePage(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{ + err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{ "User": "admin", "Active": "mail_queue", }) @@ -192,12 +228,12 @@ func TestNavMarksActivePage(t *testing.T) { } func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{ + if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{ "User": "admin", "Active": "status", }); err != nil { @@ -221,12 +257,12 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { // silently comes back at the measure, with its table squeezed into two thirds // of the column — so the set is asserted here, in both directions. func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true} - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { var buf bytes.Buffer if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { t.Fatalf("execute the wide block of %s: %v", name, err) @@ -357,12 +393,12 @@ func TestStatusPageWithoutMachineMetrics(t *testing.T) { func renderStatusPage(t *testing.T, data map[string]any) string { t.Helper() - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - if err := tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", data); err != nil { + if err := engine.Page("status").ExecuteTemplate(&buf, "layout.html", data); err != nil { t.Fatalf("execute status page: %v", err) } return buf.String() @@ -423,7 +459,7 @@ func statusPageData() map[string]any { } // dnscheckResult mirrors dnscheck.Result's shape for the template test, so the -// web package's template tests do not depend on the checker's constructor. +// view package's template tests do not depend on the checker's constructor. type dnscheckResult struct { Status health.Status Detail string diff --git a/internal/web/templates.go b/internal/web/view/view.go similarity index 69% rename from internal/web/templates.go rename to internal/web/view/view.go index 4f5abbd..469a711 100644 --- a/internal/web/templates.go +++ b/internal/web/view/view.go @@ -1,22 +1,26 @@ -package web +// Package view embeds the panel's HTML templates and static assets and renders +// pages and HTMX polling fragments. +package view import ( "bytes" + "embed" "fmt" "html/template" + "log" "net/http" + + "github.com/mixeme/selfpost/internal/legal" ) -// templates holds the parsed page and fragment templates. Each page is parsed -// together with the shared base layout so {{ template "base" . }} works. -// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are -// parsed standalone, without the layout, so they can be swapped into an -// existing page as an HTML snippet rather than a full document. Rendering -// always goes through html/template, which auto-escapes all interpolated data -// regardless (security.md). -type templates struct { +//go:embed templates/*.html static/* +var assetsFS embed.FS + +// Engine holds parsed page and fragment templates. +type Engine struct { pages map[string]*template.Template fragments map[string]*template.Template + version string } // pageFiles maps a logical page name to its template files. Every page @@ -50,10 +54,12 @@ var fragmentFiles = map[string]string{ "status_body": "templates/status_body.html", } -func loadTemplates() (*templates, error) { - t := &templates{ +// New parses embedded templates. version is stamped into every page footer. +func New(version string) (*Engine, error) { + e := &Engine{ pages: make(map[string]*template.Template), fragments: make(map[string]*template.Template), + version: version, } for name, files := range pageFiles { patterns := append([]string{"templates/layout.html"}, files...) @@ -61,22 +67,33 @@ func loadTemplates() (*templates, error) { if err != nil { return nil, fmt.Errorf("parse template %s: %w", name, err) } - t.pages[name] = tmpl + e.pages[name] = tmpl } for name, file := range fragmentFiles { tmpl, err := template.ParseFS(assetsFS, file) if err != nil { return nil, fmt.Errorf("parse fragment %s: %w", name, err) } - t.fragments[name] = tmpl + e.fragments[name] = tmpl } - return t, nil + return e, nil } -// render writes a page using the base layout. Rendering to a buffer first means +// Page returns a parsed page template by logical name. It is exported for +// template guard tests that assert structural properties across all pages. +func (e *Engine) Page(name string) *template.Template { + return e.pages[name] +} + +// Pages returns all parsed page templates keyed by logical name. +func (e *Engine) Pages() map[string]*template.Template { + return e.pages +} + +// Render writes a page using the base layout. Rendering to a buffer first means // a template error yields a clean 500 instead of a half-written page. -func (s *Server) render(w http.ResponseWriter, status int, page string, data any) { - tmpl, ok := s.tmpl.pages[page] +func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) { + tmpl, ok := e.pages[page] if !ok { http.Error(w, "template not found", http.StatusInternalServerError) return @@ -84,17 +101,19 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any // The layout's navigation compares .Active against each item, so the key // must exist on every authenticated page. Defaulting it here keeps a page // that forgets it from failing to render — it simply highlights nothing. - // .Version, shown in the layout's footer, is supplied the same way: it is - // the same value on every page, so no handler should have to pass it. + // Footer fields (.Version, .Copyright, .SourceURL) are the same on every + // page, so no handler should have to pass them. if m, ok := data.(map[string]any); ok { if _, has := m["Active"]; !has { m["Active"] = "" } - m["Version"] = s.cfg.Version + m["Version"] = e.version + m["Copyright"] = legal.CopyrightLine + m["SourceURL"] = legal.SourceURL } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { - logf("panel: render %s: %v", page, err) + log.Printf("panel: render %s: %v", page, err) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -103,18 +122,18 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any _, _ = buf.WriteTo(w) } -// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with +// RenderFragment writes an HTMX polling fragment as a bare HTML snippet, with // no surrounding layout (architecture.md § Panel HTTP surface: fragment // endpoints return HTML, not JSON). -func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) { - tmpl, ok := s.tmpl.fragments[name] +func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) { + tmpl, ok := e.fragments[name] if !ok { http.Error(w, "template not found", http.StatusInternalServerError) return } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { - logf("panel: render fragment %s: %v", name, err) + log.Printf("panel: render fragment %s: %v", name, err) http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/internal/web/web.go b/internal/web/web.go index 6d0e9c5..2341775 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,26 +1,24 @@ -// Package web implements the SelfPost control panel's HTTP surface: the +// Package web implements the SelfPost control panel's HTTP surface: the // one-time administrator setup flow (security.md), login/session handling // (security.md) and the authenticated shell the later phases build on. package web import ( - "embed" "log" "net" "net/http" - "strings" - "time" "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/legal" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/handlers" + "github.com/mixeme/selfpost/internal/web/view" ) -//go:embed templates/*.html static/* -var assetsFS embed.FS - // Config holds the panel's HTTP-facing configuration. type Config struct { // Hostname is the server's external hostname, used to build the absolute @@ -76,24 +74,9 @@ type Config struct { // Server is the panel HTTP application. type Server struct { - store *store.Store - domains *domain.Service - apps *app.Service cfg Config - tmpl *templates - sessions *sessionStore - setup *setupManager - dns *dnscheck.Checker - // machine reads the host's CPU, memory and network counters for the - // status page. It has to be one shared sampler for the whole server: - // CPU and throughput are differences between successive readings, so a - // per-request sampler would never have a previous one to subtract. - machine health.MachineSampler - - loginLimiter *rateLimiter - setupLimiter *rateLimiter - - trustedProxies []*net.IPNet + auth *auth.Module + handlers *handlers.Handlers } // New builds the panel server. setupTokenPath is where the current setup token @@ -102,118 +85,89 @@ type Server struct { // apps owns application SASL accounts and the Postfix sender map // (architecture.md § Mail path). func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { - tmpl, err := loadTemplates() + v, err := view.New(cfg.Version) if err != nil { return nil, err } - idleDays := cfg.SessionIdleDays - if idleDays <= 0 { - idleDays = 7 - } - s := &Server{ - store: st, - domains: domains, - apps: apps, - cfg: cfg, - tmpl: tmpl, - sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour), - // Published-DNS checks for the status page and the domain pages. The - // checker caches its own results, so page views do not each pay for a - // round of lookups. - dns: dnscheck.New(cfg.DNSResolvers), - // Setup: a handful of attempts per minute per IP is plenty for a - // legitimate admin and blunts automated probing (security.md). - setupLimiter: newRateLimiter(10, time.Minute), - // Login: throttle brute-force by IP (security.md). - loginLimiter: newRateLimiter(10, 15*time.Minute), - - trustedProxies: cfg.TrustedProxyCIDRs, - } - s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) - return s, nil + a := auth.New(st, auth.Config{ + CookieSecure: cfg.CookieSecure, + Hostname: cfg.Hostname, + SessionIdleDays: cfg.SessionIdleDays, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + }, v, setupTokenPath) + h := handlers.New(st, domains, apps, handlers.Config{ + Hostname: cfg.Hostname, + SubmissionEnabled: cfg.SubmissionEnabled, + MailLogPath: cfg.MailLogPath, + DataDir: cfg.DataDir, + DBPath: cfg.DBPath, + Version: cfg.Version, + TLSCertFile: cfg.TLSCertFile, + OpenDKIMSocket: cfg.OpenDKIMSocket, + JournalSocket: cfg.JournalSocket, + }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) + return &Server{cfg: cfg, auth: a, handlers: h}, nil } // Start performs first-run bootstrapping: if there is no administrator yet, it // generates and announces the setup link (security.md). Safe to call once at // server startup. func (s *Server) Start() error { - return s.setup.bootstrap() + return s.auth.Bootstrap() } // Handler returns the panel's HTTP handler (router). func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + h := s.handlers - // Health check stays unauthenticated for the container/orchestrator. mux.HandleFunc("/healthz", handleHealth) + mux.HandleFunc("/license", handleLicense) + mux.Handle("/static/", view.StaticHandler()) + mux.HandleFunc("/setup/", s.auth.HandleSetup) + mux.HandleFunc("/login", s.auth.HandleLogin) + mux.HandleFunc("/logout", s.auth.HandleLogout) - // Vendored static assets (HTMX). Served from the embedded FS, with a - // content ETag so a replaced asset survives the browser cache (static.go). - mux.Handle("/static/", staticHandler()) - - // One-time administrator setup (security.md). - mux.HandleFunc("/setup/", s.handleSetup) - - // Authentication. - mux.HandleFunc("/login", s.handleLogin) - mux.HandleFunc("/logout", s.handleLogout) - - // Authenticated panel. Everything not matched by a more specific pattern - // above falls through to this sub-mux, wrapped once in the auth middleware. authed := http.NewServeMux() - - // The landing page is the server status: the first thing an - // administrator should see after logging in is whether the service is - // healthy, not the domain list. handleLogin still redirects to "/". authed.HandleFunc("GET /{$}", redirectToStatus) - authed.HandleFunc("GET /status", s.handleStatus) - authed.HandleFunc("GET /status/fragment", s.handleStatusFragment) - authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck) + authed.HandleFunc("GET /status", h.HandleStatus) + authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment) + authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck) - authed.HandleFunc("GET /domains", s.handleDashboard) - authed.HandleFunc("POST /domains", s.handleAddDomain) - authed.HandleFunc("POST /domains/import", s.handleImportDomain) - authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) - authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck) - authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) - authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) - authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) - authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) - authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain) - authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) - authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) - authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit) - authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) - authed.HandleFunc("POST /reload", s.handleReload) + authed.HandleFunc("GET /domains", h.HandleDashboard) + authed.HandleFunc("POST /domains", h.HandleAddDomain) + authed.HandleFunc("POST /domains/import", h.HandleImportDomain) + authed.HandleFunc("GET /domains/{id}", h.HandleDomainDetail) + authed.HandleFunc("POST /domains/{id}/dns-recheck", h.HandleDomainDNSRecheck) + authed.HandleFunc("GET /domains/{id}/delete", h.HandleDeleteConfirm) + authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain) + authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication) + authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit) + authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC) + authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain) + authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode) + authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword) + authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit) + authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) + authed.HandleFunc("POST /reload", h.HandleReload) - // Administrator's own panel credentials. - authed.HandleFunc("/account", s.handleAccount) + authed.HandleFunc("/account", h.HandleAccount) - // Backup and migration: the page with both actions (architecture.md § - // Persistence-B), and the full-server backup download itself. - authed.HandleFunc("GET /backup", s.handleBackupPage) - authed.HandleFunc("POST /backup", s.handleBackup) + authed.HandleFunc("GET /backup", h.HandleBackupPage) + authed.HandleFunc("POST /backup", h.HandleBackup) - // Monitoring screens (architecture.md § Panel HTTP surface): each page and - // its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows - // and /body endpoints return HTML, not JSON). - authed.HandleFunc("GET /deliveries", s.handleDeliveries) - authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) - authed.HandleFunc("GET /deliveries/{id}", s.handleDelivery) - authed.HandleFunc("GET /mail-queue", s.handleMailQueue) - authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody) - authed.HandleFunc("GET /system-log", s.handleSystemLog) - authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody) + authed.HandleFunc("GET /deliveries", h.HandleDeliveries) + authed.HandleFunc("GET /deliveries/rows", h.HandleDeliveriesRows) + authed.HandleFunc("GET /deliveries/{id}", h.HandleDelivery) + authed.HandleFunc("GET /mail-queue", h.HandleMailQueue) + authed.HandleFunc("GET /mail-queue/body", h.HandleMailQueueBody) + authed.HandleFunc("GET /system-log", h.HandleSystemLog) + authed.HandleFunc("GET /system-log/body", h.HandleSystemLogBody) - mux.Handle("/", s.requireAuth(authed)) - - // Security headers and the origin check wrap everything, including the - // unauthenticated login and setup routes. + mux.Handle("/", s.auth.RequireAuth(authed)) return s.secure(mux) } -// redirectToStatus points the panel root at the status page, so there is one -// canonical URL for that content instead of two. func redirectToStatus(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/status", http.StatusSeeOther) } @@ -228,43 +182,20 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) } -// 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. With no trusted -// proxies configured, behind a reverse proxy this is the proxy's own address, -// which is an acceptable backstop for a single-admin panel. -func clientIP(r *http.Request, trustedProxies []*net.IPNet) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr +func handleLicense(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return } - - 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() - } - } - } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return } - - return host + _, _ = w.Write(legal.License) } -func ipInAny(ip net.IP, nets []*net.IPNet) bool { - for _, n := range nets { - if n.Contains(ip) { - return true - } - } - return false -} - -// logf is a thin wrapper so handlers log with a consistent prefix. func logf(format string, args ...any) { log.Printf(format, args...) }